diff --git a/.bazelrc b/.bazelrc index 12191bd9c4..2e3d53b620 100644 --- a/.bazelrc +++ b/.bazelrc @@ -4,6 +4,7 @@ common --repo_env=BAZEL_NO_APPLE_CPP_TOOLCHAIN=1 common --disk_cache=~/.cache/bazel-disk-cache common --repo_contents_cache=~/.cache/bazel-repo-contents-cache common --repository_cache=~/.cache/bazel-repo-cache +startup --experimental_remote_repo_contents_cache common --experimental_platform_in_output_dir diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000000..f7ee06693c --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +9.0.0 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index acc8b6bafe..f058f8dbef 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -20,6 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.92 - name: Validate tag matches Cargo.toml version shell: bash @@ -45,6 +46,15 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" + - name: Verify config schema fixture + shell: bash + working-directory: codex-rs + run: | + set -euo pipefail + echo "If this fails, run: just write-config-schema to overwrite fixture with intentional changes." + cargo run -p codex-core --bin codex-write-config-schema + git diff --exit-code core/config.schema.json + build: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} @@ -358,6 +368,10 @@ jobs: ls -R dist/ + - name: Add config schema release asset + run: | + cp codex-rs/core/config.schema.json dist/config-schema.json + - name: Define release name id: release_name run: | @@ -428,6 +442,19 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json + - name: Trigger developers.openai.com deploy + # Only trigger the deploy if the release is not a pre-release. + # The deploy is used to update the developers.openai.com website with the new config schema json file. + if: ${{ !contains(steps.release_name.outputs.name, '-') }} + continue-on-error: true + env: + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi + # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index bc49a90fa6..62a4989b77 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -198,7 +198,7 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://github.com/bminor/bash /tmp/bash + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash cd /tmp/bash git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b @@ -240,7 +240,7 @@ jobs: shell: bash run: | set -euo pipefail - git clone --depth 1 https://github.com/bminor/bash /tmp/bash + git clone --depth 1 https://github.com/bolinfest/bash /tmp/bash cd /tmp/bash git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b diff --git a/AGENTS.md b/AGENTS.md index 9c14089e5f..427bf92fef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,7 @@ In the codex-rs folder where the rust code lives: Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. -2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. - When running interactively, ask the user before running `just fix` to finalize. `just fmt` does not require approval. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. +2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. ## TUI style conventions diff --git a/MODULE.bazel b/MODULE.bazel index 2547325c9d..87db7d1522 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,13 +2,9 @@ bazel_dep(name = "platforms", version = "1.0.0") bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.3.1") archive_override( module_name = "toolchains_llvm_bootstrapped", - integrity = "sha256-9ks21bgEqbQWmwUIvqeLA64+Jk6o4ZVjC8KxjVa2Vw8=", - strip_prefix = "toolchains_llvm_bootstrapped-e3775e66a7b6d287c705ca0cd24497ef4a77c503", - urls = ["https://github.com/cerisier/toolchains_llvm_bootstrapped/archive/e3775e66a7b6d287c705ca0cd24497ef4a77c503/master.tar.gz"], - patch_strip = 1, - patches = [ - "//patches:llvm_toolchain_archive_params.patch", - ], + integrity = "sha256-4/2h4tYSUSptxFVI9G50yJxWGOwHSeTeOGBlaLQBV8g=", + strip_prefix = "toolchains_llvm_bootstrapped-d20baf67e04d8e2887e3779022890d1dc5e6b948", + urls = ["https://github.com/cerisier/toolchains_llvm_bootstrapped/archive/d20baf67e04d8e2887e3779022890d1dc5e6b948.tar.gz"], ) osx = use_extension("@toolchains_llvm_bootstrapped//toolchain/extension:osx.bzl", "osx") @@ -94,7 +90,7 @@ crate.annotation( crate = "windows-link", patch_args = ["-p1"], patches = [ - "//patches:windows-link.patch" + "//patches:windows-link.patch", ], ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7a7f9ce498..f0bb43940d 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1,5 +1,5 @@ { - "lockFileVersion": 24, + "lockFileVersion": 26, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -9,11 +9,19 @@ "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", - "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", + "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", + "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", + "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", "https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25", - "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", "https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8", - "https://bcr.bazel.build/modules/apple_support/1.24.1/source.json": "cf725267cbacc5f028ef13bb77e7f2c2e0066923a4dab1025e4a0511b1ed258a", + "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", + "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca", "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456", @@ -21,16 +29,21 @@ "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.2/MODULE.bazel": "598e7fe3b54f5fa64fdbeead1027653963a359cc23561d43680006f3b463d5a4", "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.2/source.json": "c6f5c39e6f32eb395f8fdaea63031a233bbe96d49a3bfb9f75f6fce9b74bec6c", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", "https://bcr.bazel.build/modules/bazel_features/1.24.0/MODULE.bazel": "4796b4c25b47053e9bbffa792b3792d07e228ff66cd0405faef56a978708acd4", "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", "https://bcr.bazel.build/modules/bazel_features/1.32.0/MODULE.bazel": "095d67022a58cb20f7e20e1aefecfa65257a222c18a938e2914fd257b5f1ccdc", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.34.0/source.json": "dfa5c4b01110313153b484a735764d247fee5624bbab63d25289e43b151a657a", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", @@ -52,20 +65,25 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", - "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", - "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/buildozer/8.2.1/MODULE.bazel": "61e9433c574c2bd9519cad7fa66b9c1d2b8e8d5f3ae5d6528a2c2d26e68d874d", + "https://bcr.bazel.build/modules/buildozer/8.2.1/source.json": "7c33f6a26ee0216f85544b4bca5e9044579e0219b6898dd653f5fb449cf2e484", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8", "https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2", "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", - "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4", "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f", "https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5", "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", - "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92", @@ -83,21 +101,28 @@ "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", - "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", - "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", - "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981", + "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", - "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", - "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", - "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", @@ -106,35 +131,37 @@ "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", + "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", + "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", - "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e", "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", - "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", - "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", - "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", - "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", - "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc", + "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", + "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_java/9.0.3/MODULE.bazel": "1f98ed015f7e744a745e0df6e898a7c5e83562d6b759dfd475c76456dda5ccea", + "https://bcr.bazel.build/modules/rules_java/9.0.3/source.json": "b038c0c07e12e658135bbc32cc1a2ded6e33785105c9d41958014c592de4593e", "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", - "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", - "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", - "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197", - "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", @@ -150,34 +177,47 @@ "https://bcr.bazel.build/modules/rules_platform/0.1.0/source.json": "98becf9569572719b65f639133510633eb3527fb37d347d7ef08447f3ebcf1c9", "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", - "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", - "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", - "https://bcr.bazel.build/modules/rules_python/0.40.0/source.json": "939d4bd2e3110f27bfb360292986bb79fd8dcefb874358ccd6cdaa7bda029320", + "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", "https://bcr.bazel.build/modules/rules_rs/0.0.23/MODULE.bazel": "2e7ae2044105b1873a451c628713329d6746493f677b371f9d8063fd06a00937", "https://bcr.bazel.build/modules/rules_rs/0.0.23/source.json": "1149e7f599f2e41e9e9de457f9c4deb3d219a4fec967cea30557d02ede88037e", "https://bcr.bazel.build/modules/rules_rust/0.66.0/MODULE.bazel": "86ef763a582f4739a27029bdcc6c562258ed0ea6f8d58294b049e215ceb251b3", "https://bcr.bazel.build/modules/rules_rust/0.68.1/MODULE.bazel": "8d3332ef4079673385eb81f8bd68b012decc04ac00c9d5a01a40eff90301732c", "https://bcr.bazel.build/modules/rules_rust/0.68.1/source.json": "3378e746f81b62457fdfd37391244fa8ff075ba85c05931ee4f3a20ac1efe963", "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", "https://bcr.bazel.build/modules/rules_shell/0.4.0/MODULE.bazel": "0f8f11bb3cd11755f0b48c1de0bbcf62b4b34421023aa41a2fc74ef68d9584f0", "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", - "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", - "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", - "https://bcr.bazel.build/modules/stardoc/0.7.1/source.json": "b6500ffcd7b48cd72c29bb67bcac781e12701cc0d6d55d266a652583cfcdab01", + "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", + "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658", @@ -197,9 +237,10 @@ "general": { "bzlTransitiveDigest": "dnnhvKMf9MIXMulhbhHBblZdDAfAkiSVjApIXpUz9Y8=", "usagesDigest": "dPuxg6asjUidjHZi+xFfMiW+r9RawVYGjTZnOeP+fLI=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+", + "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+" + ], "generatedRepoSpecs": { "aspect_tools_telemetry_report": { "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository", @@ -246,28 +287,16 @@ } } } - }, - "recordedRepoMappingEntries": [ - [ - "aspect_tools_telemetry+", - "bazel_lib", - "bazel_lib+" - ], - [ - "aspect_tools_telemetry+", - "bazel_skylib", - "bazel_skylib+" - ] - ] + } } }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "rL/34P1aFDq2GqVC2zCFgQ8nTuOC6ziogocpvG50Qz8=", + "bzlTransitiveDigest": "ABI1D/sbS1ovwaW/kHDoj8nnXjQ0oKU9fzmzEG4iT8o=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", - "recordedFileInputs": {}, - "recordedDirentsInputs": {}, - "envVariables": {}, + "recordedInputs": [ + "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" + ], "generatedRepoSpecs": { "com_github_jetbrains_kotlin_git": { "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", @@ -315,14 +344,205 @@ ] } } - }, - "recordedRepoMappingEntries": [ - [ - "rules_kotlin+", - "bazel_tools", - "bazel_tools" - ] - ] + } + } + }, + "@@rules_python+//python/extensions:config.bzl%config": { + "general": { + "bzlTransitiveDigest": "2hLgIvNVTLgxus0ZuXtleBe70intCfo0cHs8qvt6cdM=", + "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", + "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", + "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", + "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", + "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", + "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", + "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", + "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", + "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", + "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", + "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", + "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", + "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", + "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", + "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" + ], + "generatedRepoSpecs": { + "rules_python_internal": { + "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", + "attributes": { + "transition_setting_generators": {}, + "transition_settings": [] + } + }, + "pypi__build": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", + "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__click": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", + "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__colorama": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__importlib_metadata": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", + "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__installer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", + "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__more_itertools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", + "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__packaging": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", + "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pep517": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", + "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", + "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip_tools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", + "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pyproject_hooks": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", + "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__setuptools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", + "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__tomli": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", + "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__wheel": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", + "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__zipp": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", + "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + } + } + } + }, + "@@rules_python+//python/uv:uv.bzl%uv": { + "general": { + "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", + "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,platforms platforms" + ], + "generatedRepoSpecs": { + "uv": { + "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", + "attributes": { + "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", + "toolchain_names": [ + "none" + ], + "toolchain_implementations": { + "none": "'@@rules_python+//python:none'" + }, + "toolchain_compatible_with": { + "none": [ + "@platforms//:incompatible" + ] + }, + "toolchain_target_settings": {} + } + } + } } } }, @@ -355,7 +575,7 @@ "anstyle_1.0.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "anyhow_1.0.100": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.51\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.6\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", - "arc-swap_1.7.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.130\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arc-swap_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -406,7 +626,7 @@ "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", - "chrono_0.4.42": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.63\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", + "chrono_0.4.43": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.0\"},{\"name\":\"defmt\",\"optional\":true,\"req\":\"^1.0.1\"},{\"features\":[\"fallback\"],\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.45\",\"target\":\"cfg(unix)\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"pure-rust-locales\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.43\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.99\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.66\"},{\"name\":\"windows-link\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(windows)\"}],\"features\":{\"__internal_bench\":[],\"alloc\":[],\"clock\":[\"winapi\",\"iana-time-zone\",\"now\"],\"core-error\":[],\"default\":[\"clock\",\"std\",\"oldtime\",\"wasmbind\"],\"defmt\":[\"dep:defmt\",\"pure-rust-locales?/defmt\"],\"libc\":[],\"now\":[\"std\"],\"oldtime\":[],\"rkyv\":[\"dep:rkyv\",\"rkyv/size_32\"],\"rkyv-16\":[\"dep:rkyv\",\"rkyv?/size_16\"],\"rkyv-32\":[\"dep:rkyv\",\"rkyv?/size_32\"],\"rkyv-64\":[\"dep:rkyv\",\"rkyv?/size_64\"],\"rkyv-validation\":[\"rkyv?/validation\"],\"std\":[\"alloc\"],\"unstable-locales\":[\"pure-rust-locales\"],\"wasmbind\":[\"wasm-bindgen\",\"js-sys\"],\"winapi\":[\"windows-link\"]}}", "chunked_transfer_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{}}", "cipher_0.4.4": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.6\"},{\"name\":\"inout\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[],\"block-padding\":[\"inout/block-padding\"],\"dev\":[\"blobby\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\",\"inout/std\"]}}", "clap_4.5.54": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.14\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"clap-cargo\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"clap_builder\",\"req\":\"=4.5.54\"},{\"name\":\"clap_derive\",\"optional\":true,\"req\":\"=4.5.49\"},{\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"kind\":\"dev\",\"name\":\"shlex\",\"req\":\"^1.3.0\"},{\"features\":[\"term-svg\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.16\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"features\":[\"color-auto\",\"diff\",\"examples\"],\"kind\":\"dev\",\"name\":\"trycmd\",\"req\":\"^0.15.3\"}],\"features\":{\"cargo\":[\"clap_builder/cargo\"],\"color\":[\"clap_builder/color\"],\"debug\":[\"clap_builder/debug\",\"clap_derive?/debug\"],\"default\":[\"std\",\"color\",\"help\",\"usage\",\"error-context\",\"suggestions\"],\"deprecated\":[\"clap_builder/deprecated\",\"clap_derive?/deprecated\"],\"derive\":[\"dep:clap_derive\"],\"env\":[\"clap_builder/env\"],\"error-context\":[\"clap_builder/error-context\"],\"help\":[\"clap_builder/help\"],\"std\":[\"clap_builder/std\"],\"string\":[\"clap_builder/string\"],\"suggestions\":[\"clap_builder/suggestions\"],\"unicode\":[\"clap_builder/unicode\"],\"unstable-derive-ui-tests\":[],\"unstable-doc\":[\"clap_builder/unstable-doc\",\"derive\"],\"unstable-ext\":[\"clap_builder/unstable-ext\"],\"unstable-markdown\":[\"clap_derive/unstable-markdown\"],\"unstable-styles\":[\"clap_builder/unstable-styles\"],\"unstable-v5\":[\"clap_builder/unstable-v5\",\"clap_derive?/unstable-v5\",\"deprecated\"],\"usage\":[\"clap_builder/usage\"],\"wrap_help\":[\"clap_builder/wrap_help\"]}}", @@ -421,7 +641,6 @@ "colorchoice_1.0.4": "{\"dependencies\":[],\"features\":{}}", "combine_4.6.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"bytes_05\",\"optional\":true,\"package\":\"bytes\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"bytes_05\",\"package\":\"bytes\",\"req\":\"^0.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-03-dep\",\"package\":\"futures\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-core-03\",\"optional\":true,\"package\":\"futures-core\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"futures-io-03\",\"optional\":true,\"package\":\"futures-io\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.3\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.0\"},{\"features\":[\"tokio\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.3\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quick-error\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.6\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio-02-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.2.3\"},{\"features\":[\"fs\",\"io-driver\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio-02-dep\",\"package\":\"tokio\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"tokio-03-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^0.3\"},{\"features\":[\"fs\",\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio-03-dep\",\"package\":\"tokio\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"tokio-dep\",\"optional\":true,\"package\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\",\"macros\",\"rt\",\"rt-multi-thread\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio-dep\",\"package\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"codec\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"futures-03\":[\"pin-project\",\"std\",\"futures-core-03\",\"futures-io-03\",\"pin-project-lite\"],\"mp4\":[],\"pin-project\":[\"pin-project-lite\"],\"std\":[\"memchr/std\",\"bytes\",\"alloc\"],\"tokio\":[\"tokio-dep\",\"tokio-util/io\",\"futures-core-03\",\"pin-project-lite\"],\"tokio-02\":[\"pin-project\",\"std\",\"tokio-02-dep\",\"futures-core-03\",\"pin-project-lite\",\"bytes_05\"],\"tokio-03\":[\"pin-project\",\"std\",\"tokio-03-dep\",\"futures-core-03\",\"pin-project-lite\"]}}", "compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}", - "compact_str_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.15\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.8.8\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[],\"zeroize\":[\"dep:zeroize\"]}}", "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", "const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}", @@ -439,9 +658,9 @@ "crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-common_0.1.6": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", - "ctor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", + "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", - "ctor_0.5.0": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.6\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", "darling_0.20.11": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.20.11\"},{\"name\":\"darling_macro\",\"req\":\"=0.20.11\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.21.3": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.21.3\"},{\"name\":\"darling_macro\",\"req\":\"=0.21.3\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", "darling_0.23.0": "{\"dependencies\":[{\"name\":\"darling_core\",\"req\":\"=0.23.0\"},{\"name\":\"darling_macro\",\"req\":\"=0.23.0\"},{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.86\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.9\",\"target\":\"cfg(compiletests)\"},{\"kind\":\"dev\",\"name\":\"syn\",\"req\":\"^2.0.15\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.89\",\"target\":\"cfg(compiletests)\"}],\"features\":{\"default\":[\"suggestions\"],\"diagnostics\":[\"darling_core/diagnostics\"],\"serde\":[\"darling_core/serde\"],\"suggestions\":[\"darling_core/suggestions\"]}}", @@ -477,7 +696,6 @@ "display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}", "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "doc-comment_0.3.3": "{\"dependencies\":[],\"features\":{\"no_core\":[],\"old_macros\":[]}}", - "document-features_0.2.12": "{\"dependencies\":[{\"name\":\"litrs\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"self-test\":[]}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", "downcast-rs_1.2.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "dtor-proc-macro_0.0.6": "{\"dependencies\":[],\"features\":{\"default\":[]}}", @@ -541,6 +759,8 @@ "getrandom_0.2.16": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.62\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.18\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"}],\"features\":{\"custom\":[],\"js\":[\"wasm-bindgen\",\"js-sys\"],\"linux_disable_fallback\":[],\"rdrand\":[],\"rustc-dep-of-std\":[\"compiler_builtins\",\"core\",\"libc/rustc-dep-of-std\",\"wasi/rustc-dep-of-std\"],\"std\":[],\"test-in-browser\":[]}}", "getrandom_0.3.3": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), not(any(all(target_os = \\\"linux\\\", target_env = \\\"\\\"), getrandom_backend = \\\"custom\\\", getrandom_backend = \\\"linux_raw\\\", getrandom_backend = \\\"rdrand\\\", getrandom_backend = \\\"rndr\\\"))))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"hurd\\\", target_os = \\\"illumos\\\", target_os = \\\"cygwin\\\", all(target_os = \\\"horizon\\\", target_arch = \\\"arm\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"haiku\\\", target_os = \\\"redox\\\", target_os = \\\"nto\\\", target_os = \\\"aix\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\", target_os = \\\"tvos\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"openbsd\\\", target_os = \\\"vita\\\", target_os = \\\"emscripten\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"netbsd\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"solaris\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.154\",\"target\":\"cfg(target_os = \\\"vxworks\\\")\"},{\"default_features\":false,\"name\":\"r-efi\",\"req\":\"^5.1\",\"target\":\"cfg(all(target_os = \\\"uefi\\\", getrandom_backend = \\\"efi_rng\\\"))\"},{\"default_features\":false,\"name\":\"wasi\",\"req\":\"^0.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\", target_env = \\\"p2\\\"))\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"}],\"features\":{\"rustc-dep-of-std\":[\"dep:compiler_builtins\",\"dep:core\"],\"std\":[],\"wasm_js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"]}}", "gimli_0.31.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\",\"dep:compiler_builtins\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}", + "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", "globset_0.4.16": "{\"dependencies\":[{\"name\":\"aho-corasick\",\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"req\":\"^1.6.2\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"features\":[\"std\",\"perf\",\"syntax\",\"meta\",\"nfa\",\"hybrid\"],\"name\":\"regex-automata\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-syntax\",\"req\":\"^0.8.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.188\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.107\"}],\"features\":{\"default\":[\"log\"],\"serde1\":[\"serde\"],\"simd-accel\":[]}}", @@ -614,7 +834,6 @@ "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", "js-sys_0.3.77": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.100\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", - "kasuari_0.4.11": "{\"dependencies\":[{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.11\"},{\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.4\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{\"default\":[\"std\"],\"document-features\":[\"dep:document-features\"],\"portable-atomic\":[\"dep:portable-atomic\",\"dep:portable-atomic-util\"],\"std\":[\"thiserror/std\",\"portable-atomic?/std\"]}}", "keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}", "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", "kqueue_1.1.1": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dhat\",\"req\":\"^0.3.2\"},{\"name\":\"kqueue-sys\",\"req\":\"^1.0.4\"},{\"name\":\"libc\",\"req\":\"^0.2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"}],\"features\":{}}", @@ -630,10 +849,9 @@ "linux-raw-sys_0.4.15": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", "linux-raw-sys_0.9.4": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.49\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.100\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"bootparam\":[],\"btrfs\":[],\"default\":[\"std\",\"general\",\"errno\"],\"elf\":[],\"elf_uapi\":[],\"errno\":[],\"general\":[],\"if_arp\":[],\"if_ether\":[],\"if_packet\":[],\"image\":[],\"io_uring\":[],\"ioctl\":[],\"landlock\":[],\"loop_device\":[],\"mempolicy\":[],\"net\":[],\"netlink\":[],\"no_std\":[],\"prctl\":[],\"ptrace\":[],\"rustc-dep-of-std\":[\"core\",\"compiler_builtins\",\"no_std\"],\"std\":[],\"system\":[],\"xdp\":[]}}", "litemap_0.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.110\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.110\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde\",\"alloc\"],\"testing\":[\"alloc\"],\"yoke\":[\"dep:yoke\"]}}", - "litrs_1.0.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"optional\":true,\"req\":\"^1.0.63\"},{\"name\":\"unicode-xid\",\"optional\":true,\"req\":\"^0.2.4\"}],\"features\":{\"check_suffix\":[\"unicode-xid\"]}}", "local-waker_0.1.4": "{\"dependencies\":[],\"features\":{}}", "lock_api_0.4.13": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1.1.0\"},{\"name\":\"owning_ref\",\"optional\":true,\"req\":\"^0.4.1\"},{\"default_features\":false,\"name\":\"scopeguard\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.126\"}],\"features\":{\"arc_lock\":[],\"atomic_usize\":[],\"default\":[\"atomic_usize\"],\"nightly\":[]}}", - "log_0.4.28": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.14.1\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.1\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.7\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.7\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"std\":[]}}", + "log_0.4.29": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1.0.63\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"sval\",\"optional\":true,\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval\",\"req\":\"^2.16\"},{\"kind\":\"dev\",\"name\":\"sval_derive\",\"req\":\"^2.16\"},{\"default_features\":false,\"name\":\"sval_ref\",\"optional\":true,\"req\":\"^2.16\"},{\"default_features\":false,\"features\":[\"inline-i128\"],\"name\":\"value-bag\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"test\"],\"kind\":\"dev\",\"name\":\"value-bag\",\"req\":\"^1.12\"}],\"features\":{\"kv\":[],\"kv_serde\":[\"kv_std\",\"value-bag/serde\",\"serde\"],\"kv_std\":[\"std\",\"kv\",\"value-bag/error\"],\"kv_sval\":[\"kv\",\"value-bag/sval\",\"sval\",\"sval_ref\"],\"kv_unstable\":[\"kv\",\"value-bag\"],\"kv_unstable_serde\":[\"kv_serde\",\"kv_unstable_std\"],\"kv_unstable_std\":[\"kv_std\",\"kv_unstable\"],\"kv_unstable_sval\":[\"kv_sval\",\"kv_unstable\"],\"max_level_debug\":[],\"max_level_error\":[],\"max_level_info\":[],\"max_level_off\":[],\"max_level_trace\":[],\"max_level_warn\":[],\"release_max_level_debug\":[],\"release_max_level_error\":[],\"release_max_level_info\":[],\"release_max_level_off\":[],\"release_max_level_trace\":[],\"release_max_level_warn\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "logos-derive_0.12.1": "{\"dependencies\":[{\"name\":\"beef\",\"req\":\"^0.5.0\"},{\"name\":\"fnv\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.9\"},{\"name\":\"quote\",\"req\":\"^1.0.3\"},{\"name\":\"regex-syntax\",\"req\":\"^0.6\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^1.0.17\"}],\"features\":{}}", "logos_0.12.1": "{\"dependencies\":[{\"name\":\"logos-derive\",\"optional\":true,\"req\":\"^0.12.1\"}],\"features\":{\"default\":[\"export_derive\",\"std\"],\"export_derive\":[\"logos-derive\"],\"std\":[]}}", "lru-slab_0.1.2": "{\"dependencies\":[],\"features\":{}}", @@ -761,7 +979,6 @@ "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", "rand_core_0.9.3": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", "rand_xorshift_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.118\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", - "ratatui-core_0.1.0": "{\"dependencies\":[{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bitflags\",\"req\":\"^2.10\"},{\"default_features\":false,\"name\":\"compact_str\",\"req\":\"^0.9\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"name\":\"indoc\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"use_alloc\"],\"name\":\"itertools\",\"req\":\"^0.14\"},{\"default_features\":false,\"name\":\"kasuari\",\"req\":\"^0.4\"},{\"name\":\"lru\",\"req\":\"^0.16\"},{\"name\":\"palette\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"strum\",\"req\":\"^0.27\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unicode-truncate\",\"req\":\"^2\"},{\"name\":\"unicode-width\",\"req\":\">=0.2.0, <=0.2.2\"}],\"features\":{\"anstyle\":[\"dep:anstyle\"],\"default\":[],\"layout-cache\":[\"std\"],\"palette\":[\"std\",\"dep:palette\"],\"portable-atomic\":[\"kasuari/portable-atomic\"],\"scrolling-regions\":[],\"serde\":[\"std\",\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"std\":[\"itertools/use_std\",\"thiserror/std\",\"kasuari/std\",\"compact_str/std\",\"unicode-truncate/std\",\"strum/std\"],\"underline-color\":[]}}", "ratatui-macros_0.6.0": "{\"dependencies\":[{\"features\":[\"user-hooks\"],\"kind\":\"dev\",\"name\":\"cargo-husky\",\"req\":\"^1.5.0\"},{\"name\":\"ratatui\",\"req\":\"^0.29.0\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.101\"}],\"features\":{}}", "redox_syscall_0.5.15": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"}],\"features\":{\"default\":[\"userspace\"],\"rustc-dep-of-std\":[\"core\",\"bitflags/rustc-dep-of-std\"],\"std\":[],\"userspace\":[]}}", "redox_users_0.4.6": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\",\"call\"],\"name\":\"libredox\",\"req\":\"^0.1.3\"},{\"name\":\"rust-argon2\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"auth\":[\"rust-argon2\",\"zeroize\"],\"default\":[\"auth\"]}}", @@ -907,9 +1124,8 @@ "tokio-rustls_0.26.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"argh\",\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.1\"},{\"features\":[\"pem\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.22\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^0.26\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"logging\",\"tls12\",\"aws_lc_rs\"],\"early-data\":[],\"fips\":[\"rustls/fips\"],\"logging\":[\"rustls/logging\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"rustls/tls12\"]}}", "tokio-stream_0.1.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"features\":[\"full\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.0\"}],\"features\":{\"default\":[\"time\"],\"fs\":[\"tokio/fs\"],\"full\":[\"time\",\"net\",\"io-util\",\"fs\",\"sync\",\"signal\"],\"io-util\":[\"tokio/io-util\"],\"net\":[\"tokio/net\"],\"signal\":[\"tokio/signal\"],\"sync\":[\"tokio/sync\",\"tokio-util\"],\"time\":[\"tokio/time\"]}}", "tokio-test_0.4.4": "{\"dependencies\":[{\"name\":\"async-stream\",\"req\":\"^0.3.3\"},{\"name\":\"bytes\",\"req\":\"^1.0.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.0\"},{\"features\":[\"rt\",\"sync\",\"time\",\"test-util\"],\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.2.0\"},{\"name\":\"tokio-stream\",\"req\":\"^0.1.1\"}],\"features\":{}}", - "tokio-tungstenite_0.21.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"http1\",\"server\",\"tcp\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14.25\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.11\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.27.0\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.1\"},{\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.25.0\"},{\"default_features\":false,\"name\":\"tungstenite\",\"req\":\"^0.21.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.3.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26.0\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[]}}", "tokio-util_0.7.18": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3.0\"},{\"name\":\"bytes\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.5\"},{\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.44.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\"}],\"features\":{\"__docs_rs\":[\"futures-util\"],\"codec\":[],\"compat\":[\"futures-io\"],\"default\":[],\"full\":[\"codec\",\"compat\",\"io-util\",\"time\",\"net\",\"rt\",\"join-map\"],\"io\":[],\"io-util\":[\"io\",\"tokio/rt\",\"tokio/io-util\"],\"join-map\":[\"rt\",\"hashbrown\"],\"net\":[\"tokio/net\"],\"rt\":[\"tokio/rt\",\"tokio/sync\",\"futures-util\"],\"time\":[\"tokio/time\",\"slab\"]}}", - "tokio_1.48.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", + "tokio_1.49.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-stream\",\"req\":\"^0.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1.2.1\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"futures-concurrency\",\"req\":\"^7.6.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"io-uring\",\"optional\":true,\"req\":\"^0.7.6\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.168\",\"target\":\"cfg(unix)\"},{\"features\":[\"futures\",\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\"},{\"default_features\":false,\"features\":[\"os-poll\",\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0.1\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"mio-aio\",\"req\":\"^1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"mockall\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"features\":[\"aio\",\"fs\",\"socket\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\",\"target\":\"cfg(unix)\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"signal-hook-registry\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(unix)\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.9\",\"target\":\"cfg(all(tokio_unstable, target_os = \\\"linux\\\"))\"},{\"features\":[\"all\"],\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"tokio-macros\",\"optional\":true,\"req\":\"~2.6.0\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.29\",\"target\":\"cfg(tokio_unstable)\"},{\"kind\":\"dev\",\"name\":\"tracing-mock\",\"req\":\"=0.1.0-beta.1\",\"target\":\"cfg(all(tokio_unstable, target_has_atomic = \\\"64\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.0\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.61\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"fs\":[],\"full\":[\"fs\",\"io-util\",\"io-std\",\"macros\",\"net\",\"parking_lot\",\"process\",\"rt\",\"rt-multi-thread\",\"signal\",\"sync\",\"time\"],\"io-std\":[],\"io-uring\":[\"dep:io-uring\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"dep:slab\"],\"io-util\":[\"bytes\"],\"macros\":[\"tokio-macros\"],\"net\":[\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"socket2\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_Security\",\"windows-sys/Win32_Storage_FileSystem\",\"windows-sys/Win32_System_Pipes\",\"windows-sys/Win32_System_SystemServices\"],\"process\":[\"bytes\",\"libc\",\"mio/os-poll\",\"mio/os-ext\",\"mio/net\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Threading\",\"windows-sys/Win32_System_WindowsProgramming\"],\"rt\":[],\"rt-multi-thread\":[\"rt\"],\"signal\":[\"libc\",\"mio/os-poll\",\"mio/net\",\"mio/os-ext\",\"signal-hook-registry\",\"windows-sys/Win32_Foundation\",\"windows-sys/Win32_System_Console\"],\"sync\":[],\"taskdump\":[\"dep:backtrace\"],\"test-util\":[\"rt\",\"sync\",\"time\"],\"time\":[]}}", "toml_0.5.11": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde\",\"req\":\"^1.0.97\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[],\"preserve_order\":[\"indexmap\"]}}", "toml_0.9.5": "{\"dependencies\":[{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.5\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.3.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.199\"},{\"kind\":\"dev\",\"name\":\"serde-untagged\",\"req\":\"^0.1.7\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.116\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_spanned\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"toml-test-data\",\"req\":\"^2.3.0\"},{\"features\":[\"snapshot\"],\"kind\":\"dev\",\"name\":\"toml-test-harness\",\"req\":\"^1.3.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_datetime\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_parser\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"toml_writer\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"name\":\"winnow\",\"optional\":true,\"req\":\"^0.7.10\"}],\"features\":{\"debug\":[\"std\",\"toml_parser?/debug\",\"dep:anstream\",\"dep:anstyle\"],\"default\":[\"std\",\"serde\",\"parse\",\"display\"],\"display\":[\"dep:toml_writer\"],\"fast_hash\":[\"preserve_order\",\"dep:foldhash\"],\"parse\":[\"dep:toml_parser\",\"dep:winnow\"],\"preserve_order\":[\"dep:indexmap\",\"std\"],\"serde\":[\"dep:serde\",\"toml_datetime/serde\",\"serde_spanned/serde\"],\"std\":[\"indexmap?/std\",\"serde?/std\",\"toml_parser?/std\",\"toml_writer?/std\",\"toml_datetime/std\",\"serde_spanned/std\"],\"unbounded\":[]}}", "toml_datetime_0.7.5+spec-1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.225\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"serde\":[\"dep:serde_core\"],\"std\":[\"alloc\",\"serde_core?/std\"]}}", @@ -941,8 +1157,6 @@ "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", - "tui-scrollbar_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"color-eyre\",\"req\":\"^0.6\"},{\"name\":\"crossterm_0_28\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.28\"},{\"name\":\"crossterm_0_29\",\"optional\":true,\"package\":\"crossterm\",\"req\":\"^0.29\"},{\"name\":\"document-features\",\"req\":\"^0.2.11\"},{\"kind\":\"dev\",\"name\":\"ratatui\",\"req\":\"^0.30.0\"},{\"name\":\"ratatui-core\",\"req\":\"^0.1\"}],\"features\":{\"crossterm\":[\"crossterm_0_29\"],\"crossterm_0_28\":[\"dep:crossterm_0_28\"],\"crossterm_0_29\":[\"dep:crossterm_0_29\"],\"default\":[]}}", - "tungstenite_0.21.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.3.2\"},{\"name\":\"bytes\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\"},{\"name\":\"data-encoding\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.0\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"httparse\",\"optional\":true,\"req\":\"^1.3.4\"},{\"kind\":\"dev\",\"name\":\"input_buffer\",\"req\":\"^0.5.0\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.3\"},{\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.22.0\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.7.0\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.5.5\"},{\"name\":\"thiserror\",\"req\":\"^1.0.23\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.0\"},{\"name\":\"utf-8\",\"req\":\"^0.7.5\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\",\"url\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"]}}", "typenum_1.18.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -952,7 +1166,6 @@ "unicode-linebreak_0.1.5": "{\"dependencies\":[],\"features\":{}}", "unicode-segmentation_1.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.7\"}],\"features\":{\"no_std\":[]}}", "unicode-truncate_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "unicode-truncate_2.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"itertools\",\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"unicode-segmentation\",\"req\":\"^1\"},{\"name\":\"unicode-width\",\"req\":\"^0.2\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "unicode-width_0.1.14": "{\"dependencies\":[{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\",\"compiler_builtins\"]}}", "unicode-width_0.2.1": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0\"},{\"name\":\"std\",\"optional\":true,\"package\":\"rustc-std-workspace-std\",\"req\":\"^1.0\"}],\"features\":{\"cjk\":[],\"default\":[\"cjk\"],\"no_std\":[],\"rustc-dep-of-std\":[\"std\",\"core\"]}}", "unicode-xid_0.2.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"}],\"features\":{\"bench\":[],\"default\":[],\"no_std\":[]}}", @@ -993,6 +1206,7 @@ "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "webbrowser_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-files\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.0\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\">=0.3, <=0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"NSDictionary\",\"NSString\",\"NSURL\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.10\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"Window\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"disable-wsl\":[],\"hardened\":[],\"wasm-console\":[\"web-sys/console\"]}}", "webpki-root-certs_1.0.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}", + "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "webpki-roots_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.10": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js old mode 100644 new mode 100755 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 71f493ac27..9cb9a4e1b7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1167,8 +1167,6 @@ dependencies = [ "codex-rmcp-client", "codex-stdio-to-uds", "codex-tui", - "codex-tui2", - "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-windows-sandbox", "libc", @@ -1758,6 +1756,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-login", + "codex-otel", "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1813,87 +1812,13 @@ dependencies = [ "winsplit", ] -[[package]] -name = "codex-tui2" -version = "0.0.0" -dependencies = [ - "anyhow", - "arboard", - "assert_matches", - "async-stream", - "base64", - "chrono", - "clap", - "codex-ansi-escape", - "codex-app-server-protocol", - "codex-arg0", - "codex-backend-client", - "codex-chatgpt", - "codex-cli", - "codex-common", - "codex-core", - "codex-feedback", - "codex-file-search", - "codex-login", - "codex-protocol", - "codex-tui", - "codex-utils-absolute-path", - "codex-utils-cargo-bin", - "codex-utils-pty", - "codex-windows-sandbox", - "color-eyre", - "crossterm", - "derive_more 2.1.1", - "diffy", - "dirs", - "dunce", - "image", - "insta", - "itertools 0.14.0", - "lazy_static", - "libc", - "mcp-types", - "pathdiff", - "pretty_assertions", - "pulldown-cmark", - "rand 0.9.2", - "ratatui", - "ratatui-core", - "ratatui-macros", - "regex-lite", - "reqwest", - "serde", - "serde_json", - "serial_test", - "shlex", - "strum 0.27.2", - "strum_macros 0.27.2", - "supports-color 3.0.2", - "tempfile", - "textwrap 0.16.2", - "tokio", - "tokio-stream", - "tokio-util", - "toml 0.9.5", - "toml_edit 0.24.0+spec-1.1.0", - "tracing", - "tracing-appender", - "tracing-subscriber", - "tree-sitter-bash", - "tree-sitter-highlight", - "tui-scrollbar", - "unicode-segmentation", - "unicode-width 0.2.1", - "url", - "uuid", - "vt100", -] - [[package]] name = "codex-utils-absolute-path" version = "0.0.0" dependencies = [ + "dirs", "path-absolutize", + "pretty_assertions", "schemars 0.8.22", "serde", "serde_json", @@ -2049,20 +1974,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2649,15 +2560,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dotenvy" version = "0.15.7" @@ -3460,7 +3362,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -3510,7 +3412,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3989,16 +3891,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" -dependencies = [ - "hashbrown 0.16.0", - "thiserror 2.0.17", -] - [[package]] name = "keyring" version = "3.6.3" @@ -4144,12 +4036,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "local-waker" version = "0.1.4" @@ -5347,7 +5233,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -5384,7 +5270,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5489,7 +5375,7 @@ source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch# dependencies = [ "bitflags 2.10.0", "cassowary", - "compact_str 0.8.1", + "compact_str", "crossterm", "indoc", "instability", @@ -5498,27 +5384,7 @@ dependencies = [ "paste", "strum 0.26.3", "unicode-segmentation", - "unicode-truncate 1.1.0", - "unicode-width 0.2.1", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" -dependencies = [ - "bitflags 2.10.0", - "compact_str 0.9.0", - "hashbrown 0.16.0", - "indoc", - "itertools 0.14.0", - "kasuari", - "lru 0.16.3", - "strum 0.27.2", - "thiserror 2.0.17", - "unicode-segmentation", - "unicode-truncate 2.0.0", + "unicode-truncate", "unicode-width 0.2.1", ] @@ -5669,7 +5535,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.2", ] [[package]] @@ -6685,9 +6551,6 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] [[package]] name = "strum_macros" @@ -7152,14 +7015,18 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -7546,32 +7413,21 @@ dependencies = [ "termcolor", ] -[[package]] -name = "tui-scrollbar" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4267311b5c7999a996ea94939b6d2b1b44a9e5cc11e76cbbb6dcca4c281df4" -dependencies = [ - "document-features", - "ratatui-core", -] - [[package]] name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +version = "0.28.0" +source = "git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e" dependencies = [ - "byteorder", "bytes", "data-encoding", "http 1.3.1", "httparse", "log", - "rand 0.8.5", + "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "url", + "thiserror 2.0.17", "utf-8", ] @@ -7642,17 +7498,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "unicode-truncate" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" -dependencies = [ - "itertools 0.13.0", - "unicode-segmentation", - "unicode-width 0.2.1", -] - [[package]] name = "unicode-width" version = "0.1.14" @@ -8042,6 +7887,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.2", +] + [[package]] name = "webpki-roots" version = "1.0.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a74fb1e828..99da378560 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -35,7 +35,6 @@ members = [ "stdio-to-uds", "otel", "tui", - "tui2", "utils/absolute-path", "utils/cargo-bin", "utils/git", @@ -93,7 +92,6 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } -codex-tui2 = { path = "tui2" } codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-cache = { path = "utils/cache" } codex-utils-cargo-bin = { path = "utils/cargo-bin" } @@ -178,11 +176,10 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" -ratatui-core = "0.1.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" -reqwest = "0.12" +reqwest = { version = "0.12", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots"] } rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" @@ -212,7 +209,7 @@ tiny_http = "0.12" tokio = "1" tokio-stream = "0.1.18" tokio-test = "0.4" -tokio-tungstenite = "0.21.0" +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls-tls-native-roots", "proxy"] } tokio-util = "0.7.18" toml = "0.9.5" toml_edit = "0.24.0" @@ -225,7 +222,6 @@ tree-sitter-bash = "0.25" zstd = "0.13" tree-sitter-highlight = "0.25.10" ts-rs = "11" -tui-scrollbar = "0.2.2" uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" @@ -303,6 +299,10 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +tokio-tungstenite = { git = "https://github.com/JakkuSakura/tokio-tungstenite", rev = "2ae536b0de793f3ddf31fc2f22d445bf1ef2023d" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } + +[patch."ssh://git@github.com/JakkuSakura/tungstenite-rs.git"] +tungstenite = { git = "https://github.com/JakkuSakura/tungstenite-rs", rev = "f514de8644821113e5d18a027d6d28a5c8cc0a6e" } diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 39090c022f..488862b842 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -503,17 +503,14 @@ impl From for V1TextElement { fn from(value: CoreTextElement) -> Self { Self { byte_range: value.byte_range.into(), - placeholder: value.placeholder, + placeholder: value._placeholder_for_conversion_only().map(str::to_string), } } } impl From for CoreTextElement { fn from(value: V1TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7af3bed922..7b6e6ed200 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -217,7 +217,6 @@ v2_enum_from_core!( } ); -// TODO(mbolin): Support in-repo layer. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -449,6 +448,10 @@ pub enum ConfigWriteErrorCode { pub struct ConfigReadParams { #[serde(default)] pub include_layers: bool, + /// Optional working directory to resolve project config layers. If specified, + /// return the effective config as seen from that directory (i.e., including any + /// project layers between `cwd` and the project/repo root). + pub cwd: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1702,24 +1705,38 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } } impl From for TextElement { fn from(value: CoreTextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) } } impl From for CoreTextElement { fn from(value: TextElement) -> Self { - Self { - byte_range: value.byte_range.into(), - placeholder: value.placeholder, - } + Self::new(value.byte_range.into(), value.placeholder) } } @@ -2350,8 +2367,7 @@ pub struct ToolRequestUserInputParams { #[ts(export_to = "v2/")] /// EXPERIMENTAL. Captures a user's answer to a request_user_input question. pub struct ToolRequestUserInputAnswer { - pub selected: Vec, - pub other: Option, + pub answers: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 9df895a70a..1407c2401d 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -258,7 +258,7 @@ fn send_message_v2_with_policies( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: user_message, - // Plain text conversion has no UI element ranges. + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -292,6 +292,7 @@ fn send_follow_up_v2( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: first_message, + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -304,6 +305,7 @@ fn send_follow_up_v2( thread_id: thread_response.thread.id.clone(), input: vec![V2UserInput::Text { text: follow_up_message, + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], ..Default::default() @@ -477,6 +479,7 @@ impl CodexClient { conversation_id: *conversation_id, items: vec![InputItem::Text { text: message.to_string(), + // Test client sends plain text without UI element ranges. text_elements: Vec::new(), }], }, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5fbc5c2afa..d42632ca3b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -505,7 +505,19 @@ Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, wi "data": [{ "cwd": "/Users/me/project", "skills": [ - { "name": "skill-creator", "description": "Create or update a Codex skill", "enabled": true } + { + "name": "skill-creator", + "description": "Create or update a Codex skill", + "enabled": true, + "interface": { + "displayName": "Skill Creator", + "shortDescription": "Create or update a Codex skill", + "iconSmall": "icon.svg", + "iconLarge": "icon-large.svg", + "brandColor": "#111111", + "defaultPrompt": "Add a new skill for triaging flaky CI." + } + } ], "errors": [] }] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 225b2aface..f9332e6f61 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1445,8 +1445,7 @@ async fn on_request_user_input_response( ( id, CoreRequestUserInputAnswer { - selected: answer.selected, - other: answer.other, + answers: answer.answers, }, ) }) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 071cf4d911..b7d76b488e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -159,6 +159,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget as CoreReviewTarget; use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; +use codex_core::read_session_meta_line; use codex_core::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; @@ -1005,7 +1006,7 @@ impl CodexMessageProcessor { async fn refresh_token_if_requested(&self, do_refresh: bool) { if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token whilte getting account: {err}"); + tracing::warn!("failed to refresh token while getting account: {err}"); } } @@ -1748,47 +1749,6 @@ impl CodexMessageProcessor { developer_instructions, } = params; - let overrides_requested = model.is_some() - || model_provider.is_some() - || cwd.is_some() - || approval_policy.is_some() - || sandbox.is_some() - || request_overrides.is_some() - || base_instructions.is_some() - || developer_instructions.is_some(); - - let config = if overrides_requested { - let typesafe_overrides = self.build_thread_config_overrides( - model, - model_provider, - cwd, - approval_policy, - sandbox, - base_instructions, - developer_instructions, - ); - match derive_config_from_params( - &self.cli_overrides, - request_overrides, - typesafe_overrides, - ) - .await - { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } else { - self.config.as_ref().clone() - }; - let thread_history = if let Some(history) = history { if history.is_empty() { self.send_invalid_request_error( @@ -1863,6 +1823,38 @@ impl CodexMessageProcessor { } }; + let history_cwd = thread_history.session_cwd(); + let typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + cwd, + approval_policy, + sandbox, + base_instructions, + developer_instructions, + ); + + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let fallback_model_provider = config.model_provider_id.clone(); match self @@ -1952,53 +1944,6 @@ impl CodexMessageProcessor { developer_instructions, } = params; - let overrides_requested = model.is_some() - || model_provider.is_some() - || cwd.is_some() - || approval_policy.is_some() - || sandbox.is_some() - || cli_overrides.is_some() - || base_instructions.is_some() - || developer_instructions.is_some(); - - let config = if overrides_requested { - let overrides = self.build_thread_config_overrides( - model, - model_provider, - cwd, - approval_policy, - sandbox, - base_instructions, - developer_instructions, - ); - - // Persist windows sandbox feature. - let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); - } - - match derive_config_from_params(&self.cli_overrides, Some(cli_overrides), overrides) - .await - { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } else { - self.config.as_ref().clone() - }; - let rollout_path = if let Some(path) = path { path } else { @@ -2041,6 +1986,58 @@ impl CodexMessageProcessor { } }; + let history_cwd = match read_session_meta_line(&rollout_path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = rollout_path.display(); + warn!("failed to read session metadata from rollout {rollout_path}: {err}"); + None + } + }; + + // Persist windows sandbox feature. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { + cli_overrides.insert( + "features.experimental_windows_sandbox".to_string(), + serde_json::json!(true), + ); + } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) + }; + let typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + cwd, + approval_policy, + sandbox, + base_instructions, + developer_instructions, + ); + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let fallback_model_provider = config.model_provider_id.clone(); let NewThread { @@ -2661,68 +2658,6 @@ impl CodexMessageProcessor { overrides, } = params; - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match overrides { - Some(overrides) => { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: request_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = overrides; - - // Persist windows sandbox feature. - let mut request_overrides = request_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - request_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); - } - - let typesafe_overrides = ConfigOverrides { - model, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - ..Default::default() - }; - - derive_config_from_params( - &self.cli_overrides, - Some(request_overrides), - typesafe_overrides, - ) - .await - } - None => Ok(self.config.as_ref().clone()), - }; - let config = match config { - Ok(cfg) => cfg, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; - return; - } - }; - let thread_history = if let Some(path) = path { match RolloutRecorder::get_rollout_history(&path).await { Ok(initial_history) => initial_history, @@ -2788,6 +2723,76 @@ impl CodexMessageProcessor { } }; + let history_cwd = thread_history.session_cwd(); + let (typesafe_overrides, request_overrides) = match overrides { + Some(overrides) => { + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox: sandbox_mode, + config: request_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = overrides; + + // Persist windows sandbox feature. + let mut request_overrides = request_overrides.unwrap_or_default(); + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { + request_overrides.insert( + "features.experimental_windows_sandbox".to_string(), + serde_json::json!(true), + ); + } + + let typesafe_overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + (typesafe_overrides, Some(request_overrides)) + } + None => ( + ConfigOverrides { + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + ..Default::default() + }, + None, + ), + }; + + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("error deriving config: {err}"), + ) + .await; + return; + } + }; + match self .thread_manager .resume_thread_with_history(config, thread_history, self.auth_manager.clone()) @@ -2847,62 +2852,6 @@ impl CodexMessageProcessor { } = params; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match overrides { - Some(overrides) => { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: cli_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = overrides; - - // Persist windows sandbox feature. - let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); - } - - let overrides = ConfigOverrides { - model, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - ..Default::default() - }; - - derive_config_from_params(&self.cli_overrides, Some(cli_overrides), overrides).await - } - None => Ok(self.config.as_ref().clone()), - }; - let config = match config { - Ok(cfg) => cfg, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; - return; - } - }; - let rollout_path = if let Some(path) = path { path } else if let Some(conversation_id) = conversation_id { @@ -2936,6 +2885,90 @@ impl CodexMessageProcessor { return; }; + let history_cwd = match read_session_meta_line(&rollout_path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = rollout_path.display(); + warn!("failed to read session metadata from rollout {rollout_path}: {err}"); + None + } + }; + + let (typesafe_overrides, request_overrides) = match overrides { + Some(overrides) => { + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox: sandbox_mode, + config: cli_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = overrides; + + // Persist windows sandbox feature. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { + cli_overrides.insert( + "features.experimental_windows_sandbox".to_string(), + serde_json::json!(true), + ); + } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) + }; + + let overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + + (overrides, request_overrides) + } + None => ( + ConfigOverrides { + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + ..Default::default() + }, + None, + ), + }; + + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("error deriving config: {err}"), + ) + .await; + return; + } + }; + let NewThread { thread_id, session_configured, @@ -4101,6 +4134,31 @@ async fn derive_config_from_params( .await } +async fn derive_config_for_cwd( + cli_overrides: &[(String, TomlValue)], + request_overrides: Option>, + typesafe_overrides: ConfigOverrides, + cwd: Option, +) -> std::io::Result { + let merged_cli_overrides = cli_overrides + .iter() + .cloned() + .chain( + request_overrides + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, json_to_toml(v))), + ) + .collect::>(); + + codex_core::config::ConfigBuilder::default() + .cli_overrides(merged_cli_overrides) + .harness_overrides(typesafe_overrides) + .fallback_cwd(cwd) + .build() + .await +} + pub(crate) async fn read_summary_from_rollout( path: &Path, fallback_provider: &str, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index fe236a52b2..815aac9977 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -53,6 +53,7 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; +use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; use tokio::process::Command; pub struct McpProcess { @@ -92,6 +93,7 @@ impl McpProcess { cmd.stderr(Stdio::piped()); cmd.env("CODEX_HOME", codex_home); cmd.env("RUST_LOG", "debug"); + cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); for (k, v) in env_overrides { match v { diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 994cdf5ff8..e2e55a6544 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -59,6 +59,7 @@ pub fn create_fake_rollout( cli_version: "0.0.0".to_string(), source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + base_instructions: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, @@ -136,6 +137,7 @@ pub fn create_fake_rollout_with_text_elements( cli_version: "0.0.0".to_string(), source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + base_instructions: None, }; let payload = serde_json::to_value(SessionMetaLine { meta, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 18311d324b..8fbe2af186 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -18,7 +18,10 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; +use codex_core::config::set_project_trust_level; use codex_core::config_loader::SYSTEM_CONFIG_TOML_FILE_UNIX; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -53,6 +56,7 @@ sandbox_mode = "workspace-write" let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -101,6 +105,7 @@ view_image = false let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -141,6 +146,52 @@ view_image = false Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_project_layers_for_cwd() -> Result<()> { + let codex_home = TempDir::new()?; + write_config(&codex_home, r#"model = "gpt-user""#)?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + let project_config = AbsolutePathBuf::try_from(project_config_dir)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, origins, .. + } = to_response(resp)?; + + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + origins.get("model_reasoning_effort").expect("origin").name, + ConfigLayerSource::Project { + dot_codex_folder: project_config + } + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; @@ -195,6 +246,7 @@ writable_roots = [{}] let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -281,6 +333,7 @@ model = "gpt-old" let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, + cwd: None, }) .await?; let read_resp: JSONRPCResponse = timeout( @@ -315,6 +368,7 @@ model = "gpt-old" let verify_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, + cwd: None, }) .await?; let verify_resp: JSONRPCResponse = timeout( @@ -411,6 +465,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, + cwd: None, }) .await?; let read_resp: JSONRPCResponse = timeout( diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index 37122e08a2..c66dd05bec 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -12,6 +12,8 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use tokio::time::timeout; @@ -52,6 +54,11 @@ async fn request_user_input_round_trip() -> Result<()> { }], model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), + collaboration_mode: Some(CollaborationMode::Plan(Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + })), ..Default::default() }) .await?; @@ -80,7 +87,7 @@ async fn request_user_input_round_trip() -> Result<()> { request_id, serde_json::json!({ "answers": { - "confirm_path": { "selected": ["yes"], "other": serde_json::Value::Null } + "confirm_path": { "answers": ["yes"] } } }), ) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index be2971397d..d2fa72ef14 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -62,7 +62,9 @@ async fn thread_resume_returns_original_thread() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed, thread); + let mut expected = thread; + expected.updated_at = resumed.updated_at; + assert_eq!(resumed, expected); Ok(()) } @@ -74,10 +76,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let preview = "Saved user message"; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".into()), + )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", @@ -179,7 +181,9 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed, thread); + let mut expected = thread; + expected.updated_at = resumed.updated_at; + assert_eq!(resumed, expected); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index eedc05cd54..36f62cf321 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -8,6 +8,9 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -69,6 +72,47 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_respects_project_config_from_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + reasoning_effort, .. + } = to_response::(resp)?; + + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 29a7d6b936..c465444709 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -142,10 +142,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 5 }, - placeholder: Some("".to_string()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 5 }, + Some("".to_string()), + )]; let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index ec5546a670..b369953495 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-protocol = { workspace = true } codex-core = { workspace = true } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 1bd36e5616..81fcd35a91 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -35,8 +35,6 @@ codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-tui = { workspace = true } -codex-tui2 = { workspace = true } -codex-utils-absolute-path = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d7c7cd288b..d620b8ab35 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -26,8 +26,8 @@ use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::ExitReason; use codex_tui::update_action::UpdateAction; -use codex_tui2 as tui2; use owo_colors::OwoColorize; +use std::io::IsTerminal; use std::path::PathBuf; use supports_color::Stream; @@ -39,13 +39,8 @@ use crate::mcp_cmd::McpCli; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; -use codex_core::features::Feature; -use codex_core::features::FeatureOverrides; -use codex_core::features::Features; use codex_core::features::is_known_feature_key; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_core::terminal::TerminalName; /// Codex CLI /// @@ -725,44 +720,43 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } -/// Run the interactive Codex TUI, dispatching to either the legacy implementation or the -/// experimental TUI v2 shim based on feature flags resolved from config. async fn run_interactive_tui( - interactive: TuiCli, + mut interactive: TuiCli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { - if is_tui2_enabled(&interactive).await? { - let result = tui2::run_main(interactive.into(), codex_linux_sandbox_exe).await?; - Ok(result.into()) - } else { - codex_tui::run_main(interactive, codex_linux_sandbox_exe).await + if let Some(prompt) = interactive.prompt.take() { + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } + + let terminal_info = codex_core::terminal::terminal_info(); + if terminal_info.name == TerminalName::Dumb { + if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { + return Ok(AppExitInfo::fatal( + "TERM is set to \"dumb\". Refusing to start the interactive TUI because no terminal is available for a confirmation prompt (stdin/stderr is not a TTY). Run in a supported terminal or unset TERM.", + )); + } + + eprintln!( + "WARNING: TERM is set to \"dumb\". Codex's interactive TUI may not work in this terminal." + ); + if !confirm("Continue anyway? [y/N]: ")? { + return Ok(AppExitInfo::fatal( + "Refusing to start the interactive TUI because TERM is set to \"dumb\". Run in a supported terminal or unset TERM.", + )); + } + } + + codex_tui::run_main(interactive, codex_linux_sandbox_exe).await } -/// Returns `Ok(true)` when the resolved configuration enables the `tui2` feature flag. -/// -/// This performs a lightweight config load (honoring the same precedence as the lower-level TUI -/// bootstrap: `$CODEX_HOME`, config.toml, profile, and CLI `-c` overrides) solely to decide which -/// TUI frontend to launch. The full configuration is still loaded later by the interactive TUI. -async fn is_tui2_enabled(cli: &TuiCli) -> std::io::Result { - let raw_overrides = cli.config_overrides.raw_overrides.clone(); - let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; - let cli_kv_overrides = overrides_cli - .parse_overrides() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; +fn confirm(prompt: &str) -> std::io::Result { + eprintln!("{prompt}"); - let codex_home = find_codex_home()?; - let cwd = cli.cwd.clone(); - let config_cwd = match cwd.as_deref() { - Some(path) => AbsolutePathBuf::from_absolute_path(path)?, - None => AbsolutePathBuf::current_dir()?, - }; - let config_toml = - load_config_as_toml_with_cli_overrides(&codex_home, &config_cwd, cli_kv_overrides).await?; - let config_profile = config_toml.get_config_profile(cli.config_profile.clone())?; - let overrides = FeatureOverrides::default(); - let features = Features::from_config(&config_toml, &config_profile, overrides); - Ok(features.enabled(Feature::Tui2)) + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let answer = input.trim(); + Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) } /// Build the final `TuiCli` for a `codex resume` invocation. @@ -855,7 +849,8 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) interactive.add_dir.extend(subcommand_cli.add_dir); } if let Some(prompt) = subcommand_cli.prompt { - interactive.prompt = Some(prompt); + // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. + interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } interactive diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 9035502af3..22cd18dde3 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -20,11 +20,12 @@ use codex_rmcp_client::perform_oauth_login; use codex_rmcp_client::supports_oauth_login; /// Subcommands: -/// - `serve` — run the MCP server on stdio /// - `list` — list configured servers (with `--json`) /// - `get` — show a single server (with `--json`) /// - `add` — add a server launcher entry to `~/.codex/config.toml` /// - `remove` — delete a server entry +/// - `login` — authenticate with MCP server using OAuth +/// - `logout` — remove OAuth credentials for MCP server #[derive(Debug, clap::Parser)] pub struct McpCli { #[clap(flatten)] diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 39e2f2fd05..b088374b89 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -24,6 +24,8 @@ use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tracing::debug; +use tracing::error; +use tracing::info; use tracing::trace; use url::Url; @@ -113,7 +115,9 @@ impl ResponsesWebsocketClient { extra_headers: HeaderMap, turn_state: Option>>, ) -> Result { - let ws_url = Url::parse(&self.provider.url_for_path("responses")) + let ws_url = self + .provider + .websocket_url_for_path("responses") .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; let mut headers = self.provider.headers.clone(); @@ -149,15 +153,30 @@ async fn connect_websocket( headers: HeaderMap, turn_state: Option>>, ) -> Result<(WsStream, bool), ApiError> { + info!("connecting to websocket: {url}"); + let mut request = url - .clone() + .as_str() .into_client_request() .map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?; request.headers_mut().extend(headers); - let (stream, response) = tokio_tungstenite::connect_async(request) - .await - .map_err(|err| map_ws_error(err, &url))?; + let response = tokio_tungstenite::connect_async(request).await; + + let (stream, response) = match response { + Ok((stream, response)) => { + info!( + "successfully connected to websocket: {url}, headers: {:?}", + response.headers() + ); + (stream, response) + } + Err(err) => { + error!("failed to connect to websocket: {err}, url: {url}"); + return Err(map_ws_error(err, &url)); + } + }; + let reasoning_included = response.headers().contains_key(X_REASONING_INCLUDED_HEADER); if let Some(turn_state) = turn_state && let Some(header_value) = response @@ -209,7 +228,7 @@ async fn run_websocket_response_stream( } }; - if let Err(err) = ws_stream.send(Message::Text(request_text)).await { + if let Err(err) = ws_stream.send(Message::Text(request_text.into())).await { return Err(ApiError::Stream(format!( "failed to send websocket request: {err}" ))); @@ -269,7 +288,7 @@ async fn run_websocket_response_stream( Message::Pong(_) => {} Message::Close(_) => { return Err(ApiError::Stream( - "websocket closed before response.completed".into(), + "websocket closed by server before response.completed".into(), )); } _ => {} diff --git a/codex-rs/codex-api/src/provider.rs b/codex-rs/codex-api/src/provider.rs index 846a25bf5e..8fba2905bf 100644 --- a/codex-rs/codex-api/src/provider.rs +++ b/codex-rs/codex-api/src/provider.rs @@ -6,6 +6,7 @@ use http::Method; use http::header::HeaderMap; use std::collections::HashMap; use std::time::Duration; +use url::Url; /// Wire-level APIs supported by a `Provider`. #[derive(Debug, Clone, PartialEq, Eq)] @@ -105,6 +106,19 @@ impl Provider { self.base_url.to_ascii_lowercase().contains("openai.azure.") || matches_azure_responses_base_url(&self.base_url) } + + pub fn websocket_url_for_path(&self, path: &str) -> Result { + let mut url = Url::parse(&self.url_for_path(path))?; + + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + "ws" | "wss" => return Ok(url), + _ => return Ok(url), + }; + let _ = url.set_scheme(scheme); + Ok(url) + } } fn matches_azure_responses_base_url(base_url: &str) -> bool { diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 37a3173f9a..f1649b36c7 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -18,6 +18,7 @@ codex_rust_crate( ), integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "models.json", "prompt.md", ], test_data_extra = [ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f39f27c61..43457cbe86 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1,500 +1,90 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ConfigToml", - "description": "Base config deserialized from ~/.codex/config.toml.", - "type": "object", - "properties": { - "analytics": { - "description": "When `false`, disables analytics across Codex product surfaces in this machine. Defaults to `true`.", - "allOf": [ - { - "$ref": "#/definitions/AnalyticsConfigToml" - } - ] - }, - "approval_policy": { - "description": "Default approval policy for executing commands.", - "allOf": [ - { - "$ref": "#/definitions/AskForApproval" - } - ] - }, - "chatgpt_base_url": { - "description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).", - "type": "string" - }, - "check_for_update_on_startup": { - "description": "When `true`, checks for Codex updates on startup and surfaces update prompts. Set to `false` only if your Codex updates are centrally managed. Defaults to `true`.", - "type": "boolean" - }, - "cli_auth_credentials_store": { - "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file.", - "default": null, - "allOf": [ - { - "$ref": "#/definitions/AuthCredentialsStoreMode" - } - ] - }, - "compact_prompt": { - "description": "Compact prompt used for history compaction.", - "type": "string" - }, - "developer_instructions": { - "description": "Developer instructions inserted as a `developer` role message.", - "default": null, - "type": "string" - }, - "disable_paste_burst": { - "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts.", - "type": "boolean" - }, - "experimental_compact_prompt_file": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "experimental_instructions_file": { - "description": "Legacy, now use features", - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ] - }, - "experimental_use_freeform_apply_patch": { - "type": "boolean" - }, - "experimental_use_unified_exec_tool": { - "type": "boolean" - }, - "features": { - "description": "Centralized feature flags (new). Prefer this over individual toggles.", - "default": null, - "type": "object", - "properties": { - "apply_patch_freeform": { - "type": "boolean" - }, - "child_agents_md": { - "type": "boolean" - }, - "collab": { - "type": "boolean" - }, - "collaboration_modes": { - "type": "boolean" - }, - "connectors": { - "type": "boolean" - }, - "elevated_windows_sandbox": { - "type": "boolean" - }, - "enable_experimental_windows_sandbox": { - "type": "boolean" - }, - "enable_request_compression": { - "type": "boolean" - }, - "exec_policy": { - "type": "boolean" - }, - "experimental_use_freeform_apply_patch": { - "type": "boolean" - }, - "experimental_use_unified_exec_tool": { - "type": "boolean" - }, - "experimental_windows_sandbox": { - "type": "boolean" - }, - "include_apply_patch_tool": { - "type": "boolean" - }, - "powershell_utf8": { - "type": "boolean" - }, - "remote_compaction": { - "type": "boolean" - }, - "remote_models": { - "type": "boolean" - }, - "shell_snapshot": { - "type": "boolean" - }, - "shell_tool": { - "type": "boolean" - }, - "steer": { - "type": "boolean" - }, - "tui2": { - "type": "boolean" - }, - "undo": { - "type": "boolean" - }, - "unified_exec": { - "type": "boolean" - }, - "web_search": { - "type": "boolean" - }, - "web_search_cached": { - "type": "boolean" - }, - "web_search_request": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "feedback": { - "description": "When `false`, disables feedback collection across Codex product surfaces. Defaults to `true`.", - "allOf": [ - { - "$ref": "#/definitions/FeedbackConfigToml" - } - ] - }, - "file_opener": { - "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme.", - "allOf": [ - { - "$ref": "#/definitions/UriBasedFileOpener" - } - ] - }, - "forced_chatgpt_workspace_id": { - "description": "When set, restricts ChatGPT login to a specific workspace identifier.", - "default": null, - "type": "string" - }, - "forced_login_method": { - "description": "When set, restricts the login mechanism users may use.", - "default": null, - "allOf": [ - { - "$ref": "#/definitions/ForcedLoginMethod" - } - ] - }, - "ghost_snapshot": { - "description": "Settings for ghost snapshots (used for undo).", - "default": null, - "allOf": [ - { - "$ref": "#/definitions/GhostSnapshotToml" - } - ] - }, - "hide_agent_reasoning": { - "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", - "type": "boolean" - }, - "history": { - "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.", - "default": null, - "allOf": [ - { - "$ref": "#/definitions/History" - } - ] - }, - "instructions": { - "description": "System instructions.", - "type": "string" - }, - "mcp_oauth_callback_port": { - "description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.", - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "mcp_oauth_credentials_store": { - "description": "Preferred backend for storing MCP OAuth credentials. keyring: Use an OS-specific keyring service. https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 file: Use a file in the Codex home directory. auto (default): Use the OS-specific keyring service if available, otherwise use a file.", - "default": null, - "allOf": [ - { - "$ref": "#/definitions/OAuthCredentialsStoreMode" - } - ] - }, - "mcp_servers": { - "description": "Definition for MCP servers that Codex can reach out to for tool calls.", - "default": {}, - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/RawMcpServerConfig" - } - }, - "model": { - "description": "Optional override of model selection.", - "type": "string" - }, - "model_auto_compact_token_limit": { - "description": "Token usage threshold triggering auto-compaction of conversation history.", - "type": "integer", - "format": "int64" - }, - "model_context_window": { - "description": "Size of the context window for the model, in tokens.", - "type": "integer", - "format": "int64" - }, - "model_provider": { - "description": "Provider to use from the model_providers map.", - "type": "string" - }, - "model_providers": { - "description": "User-defined provider entries that extend/override the built-in list.", - "default": {}, - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/ModelProviderInfo" - } - }, - "model_reasoning_effort": { - "$ref": "#/definitions/ReasoningEffort" - }, - "model_reasoning_summary": { - "$ref": "#/definitions/ReasoningSummary" - }, - "model_supports_reasoning_summaries": { - "description": "Override to force-enable reasoning summaries for the configured model.", - "type": "boolean" - }, - "model_verbosity": { - "description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).", - "allOf": [ - { - "$ref": "#/definitions/Verbosity" - } - ] - }, - "notice": { - "description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notices`] for more details", - "allOf": [ - { - "$ref": "#/definitions/Notice" - } - ] - }, - "notify": { - "description": "Optional external command to spawn for end-user notifications.", - "default": null, - "type": "array", - "items": { - "type": "string" - } - }, - "oss_provider": { - "description": "Preferred OSS provider for local models, e.g. \"lmstudio\", \"ollama\", or \"ollama-chat\".", - "type": "string" - }, - "otel": { - "description": "OTEL configuration.", - "allOf": [ - { - "$ref": "#/definitions/OtelConfigToml" - } - ] - }, - "profile": { - "description": "Profile to use from the `profiles` map.", - "type": "string" - }, - "profiles": { - "description": "Named profiles to facilitate switching between different configurations.", - "default": {}, - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/ConfigProfile" - } - }, - "project_doc_fallback_filenames": { - "description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.", - "type": "array", - "items": { - "type": "string" - } - }, - "project_doc_max_bytes": { - "description": "Maximum number of bytes to include from an AGENTS.md project doc file.", - "type": "integer", - "format": "uint", - "minimum": 0.0 - }, - "project_root_markers": { - "description": "Markers used to detect the project root when searching parent directories for `.codex` folders. Defaults to [\".git\"] when unset.", - "default": null, - "type": "array", - "items": { - "type": "string" - } - }, - "projects": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/ProjectConfig" - } - }, - "review_model": { - "description": "Review model override used by the `/review` feature.", - "type": "string" - }, - "sandbox_mode": { - "description": "Sandbox mode to use.", - "allOf": [ - { - "$ref": "#/definitions/SandboxMode" - } - ] - }, - "sandbox_workspace_write": { - "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.", - "allOf": [ - { - "$ref": "#/definitions/SandboxWorkspaceWrite" - } - ] - }, - "shell_environment_policy": { - "default": { - "exclude": null, - "experimental_use_profile": null, - "ignore_default_excludes": null, - "include_only": null, - "inherit": null, - "set": null - }, - "allOf": [ - { - "$ref": "#/definitions/ShellEnvironmentPolicyToml" - } - ] - }, - "show_raw_agent_reasoning": { - "description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.", - "type": "boolean" - }, - "skills": { - "description": "User-level skill config entries keyed by SKILL.md path.", - "allOf": [ - { - "$ref": "#/definitions/SkillsConfig" - } - ] - }, - "tool_output_token_limit": { - "description": "Token budget applied when storing tool/function outputs in the context manager.", - "type": "integer", - "format": "uint", - "minimum": 0.0 - }, - "tools": { - "description": "Nested tools section for feature toggles", - "allOf": [ - { - "$ref": "#/definitions/ToolsToml" - } - ] - }, - "tui": { - "description": "Collection of settings that are specific to the TUI.", - "allOf": [ - { - "$ref": "#/definitions/Tui" - } - ] - }, - "web_search": { - "description": "Controls the web search tool mode: disabled, cached, or live.", - "allOf": [ - { - "$ref": "#/definitions/WebSearchMode" - } - ] - }, - "windows_wsl_setup_acknowledged": { - "description": "Tracks whether the Windows onboarding screen has been acknowledged.", - "type": "boolean" - } - }, "additionalProperties": false, "definitions": { "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AgentsToml": { + "additionalProperties": false, + "properties": { + "max_threads": { + "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", + "format": "uint", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, "AltScreenMode": { "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n**Background:** The alternate screen buffer provides a cleaner fullscreen experience without polluting the terminal's scrollback history. However, it conflicts with terminal multiplexers like Zellij that strictly follow the xterm specification, which defines that alternate screen buffers should not have scrollback.\n\n**Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This is by design and not configurable in Zellij—there is no option to enable scrollback in alternate screen mode.\n\n**Solution:** This setting provides a pragmatic workaround: - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij, disable alternate screen to preserve scrollback. Enable it everywhere else. - `always`: Always use alternate screen mode (original behavior before this fix). - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback in all multiplexers.\n\nThe CLI flag `--no-alt-screen` can override this setting at runtime.", "oneOf": [ { "description": "Auto-detect: disable alternate screen in Zellij, enable elsewhere.", - "type": "string", "enum": [ "auto" - ] + ], + "type": "string" }, { "description": "Always use alternate screen (original behavior).", - "type": "string", "enum": [ "always" - ] + ], + "type": "string" }, { "description": "Never use alternate screen (inline mode only).", - "type": "string", "enum": [ "never" - ] + ], + "type": "string" } ] }, "AnalyticsConfigToml": { + "additionalProperties": false, "description": "Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.", - "type": "object", "properties": { "enabled": { "description": "When `false`, disables analytics across Codex product surfaces in this profile.", "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ { "description": "Under this policy, only \"known safe\" commands—as determined by `is_safe_command()`—that **only read files** are auto‑approved. Everything else will ask the user to approve.", - "type": "string", "enum": [ "untrusted" - ] + ], + "type": "string" }, { "description": "*All* commands are auto‑approved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.", - "type": "string", "enum": [ "on-failure" - ] + ], + "type": "string" }, { "description": "The model decides when to ask the user for approval.", - "type": "string", "enum": [ "on-request" - ] + ], + "type": "string" }, { "description": "Never ask the user to approve commands. Failures are immediately returned to the model, and never escalated to the user for approval.", - "type": "string", "enum": [ "never" - ] + ], + "type": "string" } ] }, @@ -503,30 +93,30 @@ "oneOf": [ { "description": "Persist credentials in CODEX_HOME/auth.json.", - "type": "string", "enum": [ "file" - ] + ], + "type": "string" }, { "description": "Persist credentials in the keyring. Fail if unavailable.", - "type": "string", "enum": [ "keyring" - ] + ], + "type": "string" }, { "description": "Use keyring when available; otherwise, fall back to a file in CODEX_HOME.", - "type": "string", "enum": [ "auto" - ] + ], + "type": "string" } ] }, "ConfigProfile": { + "additionalProperties": false, "description": "Collection of common configuration options that a user can define as a unit in `config.toml`.", - "type": "object", "properties": { "analytics": { "$ref": "#/definitions/AnalyticsConfigToml" @@ -540,9 +130,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_instructions_file": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -550,9 +137,9 @@ "type": "boolean" }, "features": { - "description": "Optional feature toggles scoped to this profile.", + "additionalProperties": false, "default": null, - "type": "object", + "description": "Optional feature toggles scoped to this profile.", "properties": { "apply_patch_freeform": { "type": "boolean" @@ -602,6 +189,9 @@ "remote_models": { "type": "boolean" }, + "responses_websockets": { + "type": "boolean" + }, "shell_snapshot": { "type": "boolean" }, @@ -611,9 +201,6 @@ "steer": { "type": "boolean" }, - "tui2": { - "type": "boolean" - }, "undo": { "type": "boolean" }, @@ -630,7 +217,7 @@ "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, "include_apply_patch_tool": { "type": "boolean" @@ -638,6 +225,17 @@ "model": { "type": "string" }, + "model_instructions_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a file containing model instructions." + }, + "model_personality": { + "$ref": "#/definitions/Personality" + }, "model_provider": { "description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.", "type": "string" @@ -667,27 +265,27 @@ "$ref": "#/definitions/WebSearchMode" } }, - "additionalProperties": false + "type": "object" }, "FeedbackConfigToml": { - "type": "object", + "additionalProperties": false, "properties": { "enabled": { "description": "When `false`, disables the feedback flow across Codex product surfaces.", "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, "ForcedLoginMethod": { - "type": "string", "enum": [ "chatgpt", "api" - ] + ], + "type": "string" }, "GhostSnapshotToml": { - "type": "object", + "additionalProperties": false, "properties": { "disable_warnings": { "description": "Disable all ghost snapshot warning events.", @@ -695,76 +293,73 @@ }, "ignore_large_untracked_dirs": { "description": "Ignore untracked directories that contain this many files or more. (Still emits a warning unless warnings are disabled.)", - "type": "integer", - "format": "int64" + "format": "int64", + "type": "integer" }, "ignore_large_untracked_files": { "description": "Exclude untracked files larger than this many bytes from ghost snapshots.", - "type": "integer", - "format": "int64" + "format": "int64", + "type": "integer" } }, - "additionalProperties": false + "type": "object" }, "History": { + "additionalProperties": false, "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.", - "type": "object", - "required": [ - "persistence" - ], "properties": { "max_bytes": { "description": "If set, the maximum size of the history file in bytes. The oldest entries are dropped once the file exceeds this limit.", - "type": "integer", "format": "uint", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "persistence": { - "description": "If true, history entries will not be written to disk.", "allOf": [ { "$ref": "#/definitions/HistoryPersistence" } - ] + ], + "description": "If true, history entries will not be written to disk." } }, - "additionalProperties": false + "required": [ + "persistence" + ], + "type": "object" }, "HistoryPersistence": { "oneOf": [ { "description": "Save all history entries to disk.", - "type": "string", "enum": [ "save-all" - ] + ], + "type": "string" }, { "description": "Do not write history to disk.", - "type": "string", "enum": [ "none" - ] + ], + "type": "string" } ] }, "ModelProviderInfo": { + "additionalProperties": false, "description": "Serializable representation of a provider definition.", - "type": "object", - "required": [ - "name" - ], "properties": { "base_url": { "description": "Base URL for the provider's OpenAI-compatible API.", "type": "string" }, "env_http_headers": { - "description": "Optional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and _environment variable_ whose value should be used. If the environment variable is not set, or the value is empty, the header will not be included in the request.", - "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "Optional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and _environment variable_ whose value should be used. If the environment variable is not set, or the value is empty, the header will not be included in the request.", + "type": "object" }, "env_key": { "description": "Environment variable that stores the user's API key for this provider.", @@ -779,61 +374,64 @@ "type": "string" }, "http_headers": { - "description": "Additional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and value.", - "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "Additional HTTP headers to include in requests to this provider where the (key, value) pairs are the header name and value.", + "type": "object" }, "name": { "description": "Friendly display name.", "type": "string" }, "query_params": { - "description": "Optional query parameters to append to the base URL.", - "type": "object", "additionalProperties": { "type": "string" - } + }, + "description": "Optional query parameters to append to the base URL.", + "type": "object" }, "request_max_retries": { "description": "Maximum number of times to retry a failed HTTP request to this provider.", - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "requires_openai_auth": { - "description": "Does this provider require an OpenAI API Key or ChatGPT login token? If true, user is presented with login screen on first run, and login preference and token/key are stored in auth.json. If false (which is the default), login screen is skipped, and API key (if needed) comes from the \"env_key\" environment variable.", "default": false, + "description": "Does this provider require an OpenAI API Key or ChatGPT login token? If true, user is presented with login screen on first run, and login preference and token/key are stored in auth.json. If false (which is the default), login screen is skipped, and API key (if needed) comes from the \"env_key\" environment variable.", "type": "boolean" }, "stream_idle_timeout_ms": { "description": "Idle timeout (in milliseconds) to wait for activity on a streaming response before treating the connection as lost.", - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "stream_max_retries": { "description": "Number of times to retry reconnecting a dropped streaming response before failing.", - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "wire_api": { - "description": "Which wire protocol this provider expects.", - "default": "chat", "allOf": [ { "$ref": "#/definitions/WireApi" } - ] + ], + "default": "chat", + "description": "Which wire protocol this provider expects." } }, - "additionalProperties": false + "required": [ + "name" + ], + "type": "object" }, "Notice": { + "additionalProperties": false, "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", - "type": "object", "properties": { "hide_full_access_warning": { "description": "Tracks whether the user has acknowledged the full access warning prompt.", @@ -856,15 +454,15 @@ "type": "boolean" }, "model_migrations": { - "description": "Tracks acknowledged model migrations as old->new model slug mappings.", - "default": {}, - "type": "object", "additionalProperties": { "type": "string" - } + }, + "default": {}, + "description": "Tracks acknowledged model migrations as old->new model slug mappings.", + "type": "object" } }, - "additionalProperties": false + "type": "object" }, "Notifications": { "anyOf": [ @@ -872,10 +470,10 @@ "type": "boolean" }, { - "type": "array", "items": { "type": "string" - } + }, + "type": "array" } ] }, @@ -884,143 +482,143 @@ "oneOf": [ { "description": "`Keyring` when available; otherwise, `File`. Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.", - "type": "string", "enum": [ "auto" - ] + ], + "type": "string" }, { "description": "CODEX_HOME/.credentials.json This file will be readable to Codex and other applications running as the same user.", - "type": "string", "enum": [ "file" - ] + ], + "type": "string" }, { "description": "Keyring when available, otherwise fail.", - "type": "string", "enum": [ "keyring" - ] + ], + "type": "string" } ] }, "OtelConfigToml": { + "additionalProperties": false, "description": "OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.", - "type": "object", "properties": { "environment": { "description": "Mark traces with environment (dev, staging, prod, test). Defaults to dev.", "type": "string" }, "exporter": { - "description": "Optional log exporter", "allOf": [ { "$ref": "#/definitions/OtelExporterKind" } - ] + ], + "description": "Optional log exporter" }, "log_user_prompt": { "description": "Log user prompt in traces", "type": "boolean" }, "trace_exporter": { - "description": "Optional trace exporter", "allOf": [ { "$ref": "#/definitions/OtelExporterKind" } - ] + ], + "description": "Optional trace exporter" } }, - "additionalProperties": false + "type": "object" }, "OtelExporterKind": { "description": "Which OTEL exporter to use.", "oneOf": [ { - "type": "string", "enum": [ "none", "statsig" - ] + ], + "type": "string" }, { - "type": "object", - "required": [ - "otlp-http" - ], + "additionalProperties": false, "properties": { "otlp-http": { - "type": "object", - "required": [ - "endpoint", - "protocol" - ], + "additionalProperties": false, "properties": { "endpoint": { "type": "string" }, "headers": { - "default": {}, - "type": "object", "additionalProperties": { "type": "string" - } + }, + "default": {}, + "type": "object" }, "protocol": { "$ref": "#/definitions/OtelHttpProtocol" }, "tls": { - "default": null, "allOf": [ { "$ref": "#/definitions/OtelTlsConfig" } - ] + ], + "default": null } }, - "additionalProperties": false + "required": [ + "endpoint", + "protocol" + ], + "type": "object" } }, - "additionalProperties": false + "required": [ + "otlp-http" + ], + "type": "object" }, { - "type": "object", - "required": [ - "otlp-grpc" - ], + "additionalProperties": false, "properties": { "otlp-grpc": { - "type": "object", - "required": [ - "endpoint" - ], + "additionalProperties": false, "properties": { "endpoint": { "type": "string" }, "headers": { - "default": {}, - "type": "object", "additionalProperties": { "type": "string" - } + }, + "default": {}, + "type": "object" }, "tls": { - "default": null, "allOf": [ { "$ref": "#/definitions/OtelTlsConfig" } - ] + ], + "default": null } }, - "additionalProperties": false + "required": [ + "endpoint" + ], + "type": "object" } }, - "additionalProperties": false + "required": [ + "otlp-grpc" + ], + "type": "object" } ] }, @@ -1028,22 +626,22 @@ "oneOf": [ { "description": "Binary payload", - "type": "string", "enum": [ "binary" - ] + ], + "type": "string" }, { "description": "JSON payload", - "type": "string", "enum": [ "json" - ] + ], + "type": "string" } ] }, "OtelTlsConfig": { - "type": "object", + "additionalProperties": false, "properties": { "ca-certificate": { "$ref": "#/definitions/AbsolutePathBuf" @@ -1055,26 +653,33 @@ "$ref": "#/definitions/AbsolutePathBuf" } }, - "additionalProperties": false + "type": "object" + }, + "Personality": { + "enum": [ + "friendly", + "pragmatic" + ], + "type": "string" }, "ProjectConfig": { - "type": "object", + "additionalProperties": false, "properties": { "trust_level": { "$ref": "#/definitions/TrustLevel" } }, - "additionalProperties": false + "type": "object" }, "RawMcpServerConfig": { - "type": "object", + "additionalProperties": false, "properties": { "args": { "default": null, - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "bearer_token": { "type": "string" @@ -1091,10 +696,10 @@ }, "disabled_tools": { "default": null, - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "enabled": { "default": null, @@ -1102,63 +707,62 @@ }, "enabled_tools": { "default": null, - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "env": { - "default": null, - "type": "object", "additionalProperties": { "type": "string" - } + }, + "default": null, + "type": "object" }, "env_http_headers": { - "default": null, - "type": "object", "additionalProperties": { "type": "string" - } + }, + "default": null, + "type": "object" }, "env_vars": { "default": null, - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "http_headers": { - "type": "object", "additionalProperties": { "type": "string" - } + }, + "type": "object" }, "startup_timeout_ms": { "default": null, - "type": "integer", "format": "uint64", - "minimum": 0.0 + "minimum": 0.0, + "type": "integer" }, "startup_timeout_sec": { "default": null, - "type": "number", - "format": "double" + "format": "double", + "type": "number" }, "tool_timeout_sec": { "default": null, - "type": "number", - "format": "double" + "format": "double", + "type": "number" }, "url": { "type": "string" } }, - "additionalProperties": false + "type": "object" }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", - "type": "string", "enum": [ "none", "minimal", @@ -1166,38 +770,39 @@ "medium", "high", "xhigh" - ] + ], + "type": "string" }, "ReasoningSummary": { "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", "oneOf": [ { - "type": "string", "enum": [ "auto", "concise", "detailed" - ] + ], + "type": "string" }, { "description": "Option to disable reasoning summaries.", - "type": "string", "enum": [ "none" - ] + ], + "type": "string" } ] }, "SandboxMode": { - "type": "string", "enum": [ "read-only", "workspace-write", "danger-full-access" - ] + ], + "type": "string" }, "SandboxWorkspaceWrite": { - "type": "object", + "additionalProperties": false, "properties": { "exclude_slash_tmp": { "default": false, @@ -1213,75 +818,49 @@ }, "writable_roots": { "default": [], - "type": "array", "items": { "$ref": "#/definitions/AbsolutePathBuf" - } + }, + "type": "array" } }, - "additionalProperties": false - }, - "ScrollInputMode": { - "description": "How TUI2 should interpret mouse scroll events.\n\nTerminals generally encode both mouse wheels and trackpads as the same \"scroll up/down\" mouse button events, without a magnitude. This setting controls whether Codex uses a heuristic to infer wheel vs trackpad per stream, or forces a specific behavior.", - "oneOf": [ - { - "description": "Infer wheel vs trackpad behavior per scroll stream.", - "type": "string", - "enum": [ - "auto" - ] - }, - { - "description": "Always treat scroll events as mouse-wheel input (fixed lines per tick).", - "type": "string", - "enum": [ - "wheel" - ] - }, - { - "description": "Always treat scroll events as trackpad input (fractional accumulation).", - "type": "string", - "enum": [ - "trackpad" - ] - } - ] + "type": "object" }, "ShellEnvironmentPolicyInherit": { "oneOf": [ { "description": "\"Core\" environment variables for the platform. On UNIX, this would include HOME, LOGNAME, PATH, SHELL, and USER, among others.", - "type": "string", "enum": [ "core" - ] + ], + "type": "string" }, { "description": "Inherits the full environment from the parent process.", - "type": "string", "enum": [ "all" - ] + ], + "type": "string" }, { "description": "Do not inherit any environment variables from the parent process.", - "type": "string", "enum": [ "none" - ] + ], + "type": "string" } ] }, "ShellEnvironmentPolicyToml": { + "additionalProperties": false, "description": "Policy for building the `env` when spawning a process via either the `shell` or `local_shell` tool.", - "type": "object", "properties": { "exclude": { "description": "List of regular expressions.", - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "experimental_use_profile": { "type": "boolean" @@ -1291,29 +870,25 @@ }, "include_only": { "description": "List of regular expressions.", - "type": "array", "items": { "type": "string" - } + }, + "type": "array" }, "inherit": { "$ref": "#/definitions/ShellEnvironmentPolicyInherit" }, "set": { - "type": "object", "additionalProperties": { "type": "string" - } + }, + "type": "object" } }, - "additionalProperties": false + "type": "object" }, "SkillConfig": { - "type": "object", - "required": [ - "enabled", - "path" - ], + "additionalProperties": false, "properties": { "enabled": { "type": "boolean" @@ -1322,26 +897,30 @@ "$ref": "#/definitions/AbsolutePathBuf" } }, - "additionalProperties": false + "required": [ + "enabled", + "path" + ], + "type": "object" }, "SkillsConfig": { - "type": "object", + "additionalProperties": false, "properties": { "config": { - "type": "array", "items": { "$ref": "#/definitions/SkillConfig" - } + }, + "type": "array" } }, - "additionalProperties": false + "type": "object" }, "ToolsToml": { - "type": "object", + "additionalProperties": false, "properties": { "view_image": { - "description": "Enable the `view_image` tool that lets the agent attach local images.", "default": null, + "description": "Enable the `view_image` tool that lets the agent attach local images.", "type": "boolean" }, "web_search": { @@ -1349,169 +928,548 @@ "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, "TrustLevel": { "description": "Represents the trust level for a project directory. This determines the approval policy and sandbox mode applied.", - "type": "string", "enum": [ "trusted", "untrusted" - ] + ], + "type": "string" }, "Tui": { + "additionalProperties": false, "description": "Collection of settings that are specific to the TUI.", - "type": "object", "properties": { "alternate_screen": { - "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - `always`: Always use alternate screen (original behavior). - `never`: Never use alternate screen (inline mode only, preserves scrollback).\n\nUsing alternate screen provides a cleaner fullscreen experience but prevents scrollback in terminal multiplexers like Zellij that follow the xterm spec.", - "default": "auto", "allOf": [ { "$ref": "#/definitions/AltScreenMode" } - ] + ], + "default": "auto", + "description": "Controls whether the TUI uses the terminal's alternate screen buffer.\n\n- `auto` (default): Disable alternate screen in Zellij, enable elsewhere. - `always`: Always use alternate screen (original behavior). - `never`: Never use alternate screen (inline mode only, preserves scrollback).\n\nUsing alternate screen provides a cleaner fullscreen experience but prevents scrollback in terminal multiplexers like Zellij that follow the xterm spec." }, "animations": { - "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "default": true, + "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, "notifications": { - "description": "Enable desktop notifications from the TUI when the terminal is unfocused. Defaults to `true`.", - "default": true, "allOf": [ { "$ref": "#/definitions/Notifications" } - ] - }, - "scroll_events_per_tick": { - "description": "Override the *wheel* event density used to normalize TUI2 scrolling.\n\nTerminals generally deliver both mouse wheels and trackpads as discrete `scroll up/down` mouse events with direction but no magnitude. Unfortunately, the *number* of raw events per physical wheel notch varies by terminal (commonly 1, 3, or 9+). TUI2 uses this value to normalize that raw event density into consistent \"wheel tick\" behavior.\n\nWheel math (conceptually):\n\n- A single event contributes `1 / scroll_events_per_tick` tick-equivalents. - Wheel-like streams then scale that by `scroll_wheel_lines` so one physical notch scrolls a fixed number of lines.\n\nTrackpad math is intentionally *not* fully tied to this value: in trackpad-like mode, TUI2 uses `min(scroll_events_per_tick, 3)` as the divisor so terminals with dense wheel ticks (e.g. 9 events per notch) do not make trackpads feel artificially slow.\n\nDefaults are derived per terminal from [`crate::terminal::TerminalInfo`] when TUI2 starts. See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale.", - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "scroll_invert": { - "description": "Invert mouse scroll direction in TUI2.\n\nThis flips the scroll sign after terminal detection. It is applied consistently to both wheel and trackpad input.", - "default": false, - "type": "boolean" - }, - "scroll_mode": { - "description": "Select how TUI2 interprets mouse scroll input.\n\n- `auto` (default): infer wheel vs trackpad per scroll stream. - `wheel`: always use wheel behavior (fixed lines per wheel notch). - `trackpad`: always use trackpad behavior (fractional accumulation; wheel may feel slow).", - "default": "auto", - "allOf": [ - { - "$ref": "#/definitions/ScrollInputMode" - } - ] - }, - "scroll_trackpad_accel_events": { - "description": "Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2.\n\nThis keeps small swipes precise while allowing large/faster swipes to cover more content. Defaults are chosen to address terminals where trackpad event density is comparatively low.\n\nConcretely, TUI2 computes an acceleration multiplier for trackpad-like streams:\n\n- `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)`\n\nThe multiplier is applied to the stream’s computed line delta (including any carried fractional remainder).", - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "scroll_trackpad_accel_max": { - "description": "Trackpad acceleration: maximum multiplier applied to trackpad-like streams.\n\nSet to 1 to effectively disable trackpad acceleration.\n\nSee [`Tui::scroll_trackpad_accel_events`] for the exact multiplier formula.", - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "scroll_trackpad_lines": { - "description": "Override baseline trackpad scroll sensitivity in TUI2.\n\nTrackpads do not have discrete notches, but terminals still emit discrete `scroll up/down` events. In trackpad-like mode, TUI2 accumulates fractional scroll and only applies whole lines to the viewport.\n\nTrackpad per-event contribution is:\n\n- `scroll_trackpad_lines / min(scroll_events_per_tick, 3)`\n\n(plus optional bounded acceleration; see `scroll_trackpad_accel_*`). The `min(..., 3)` divisor is deliberate: `scroll_events_per_tick` is calibrated from *wheel* behavior and can be much larger than trackpad event density, which would otherwise make trackpads feel too slow in dense-wheel terminals.\n\nDefaults to 1, meaning one tick-equivalent maps to one transcript line.", - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "scroll_wheel_like_max_duration_ms": { - "description": "Auto-mode fallback: maximum duration (ms) that a very small stream is still treated as wheel-like.\n\nThis is only used when `scroll_events_per_tick` is effectively 1 (one event per wheel notch). In that case, we cannot observe a \"tick completion time\", so TUI2 treats a short-lived, small stream (<= 2 events) as wheel-like to preserve classic wheel behavior.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "scroll_wheel_lines": { - "description": "Override how many transcript lines one physical *wheel notch* should scroll in TUI2.\n\nThis is the \"classic feel\" knob. Defaults to 3.\n\nWheel-like per-event contribution is `scroll_wheel_lines / scroll_events_per_tick`. For example, in a terminal that emits 9 events per notch, the default `3 / 9` yields 1/3 of a line per event and totals 3 lines once the full notch burst arrives.\n\nSee `codex-rs/tui2/docs/scroll_input_model.md` for details on the stream model and the wheel/trackpad heuristic.", - "type": "integer", - "format": "uint16", - "minimum": 0.0 - }, - "scroll_wheel_tick_detect_max_ms": { - "description": "Auto-mode threshold: maximum time (ms) for the first tick-worth of events to arrive.\n\nIn `scroll_mode = \"auto\"`, TUI2 starts a stream as trackpad-like (to avoid overshoot) and promotes it to wheel-like if `scroll_events_per_tick` events arrive \"quickly enough\". This threshold controls what \"quickly enough\" means.\n\nMost users should leave this unset; it is primarily for terminals that emit wheel ticks batched over longer time spans.", - "type": "integer", - "format": "uint64", - "minimum": 0.0 + ], + "default": true, + "description": "Enable desktop notifications from the TUI when the terminal is unfocused. Defaults to `true`." }, "show_tooltips": { - "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", "default": true, + "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", "type": "boolean" } }, - "additionalProperties": false + "type": "object" }, "UriBasedFileOpener": { "oneOf": [ { - "type": "string", "enum": [ "vscode", "vscode-insiders", "windsurf", "cursor" - ] + ], + "type": "string" }, { "description": "Option to disable the URI-based file opener.", - "type": "string", "enum": [ "none" - ] + ], + "type": "string" } ] }, "Verbosity": { "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", - "type": "string", "enum": [ "low", "medium", "high" - ] + ], + "type": "string" }, "WebSearchMode": { - "type": "string", "enum": [ "disabled", "cached", "live" - ] + ], + "type": "string" }, "WireApi": { "description": "Wire protocol that the provider speaks. Most third-party services only implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI itself (and a handful of others) additionally expose the more modern *Responses* API. The two protocols use different request/response shapes and *cannot* be auto-detected at runtime, therefore each provider entry must declare which one it expects.", "oneOf": [ { "description": "The Responses API exposed by OpenAI at `/v1/responses`.", - "type": "string", "enum": [ "responses" - ] + ], + "type": "string" }, { "description": "Experimental: Responses API over WebSocket transport.", - "type": "string", "enum": [ "responses_websocket" - ] + ], + "type": "string" }, { "description": "Regular Chat Completions compatible with `/v1/chat/completions`.", - "type": "string", "enum": [ "chat" - ] + ], + "type": "string" } ] } - } + }, + "description": "Base config deserialized from ~/.codex/config.toml.", + "properties": { + "agents": { + "allOf": [ + { + "$ref": "#/definitions/AgentsToml" + } + ], + "description": "Agent-related settings (thread limits, etc.)." + }, + "analytics": { + "allOf": [ + { + "$ref": "#/definitions/AnalyticsConfigToml" + } + ], + "description": "When `false`, disables analytics across Codex product surfaces in this machine. Defaults to `true`." + }, + "approval_policy": { + "allOf": [ + { + "$ref": "#/definitions/AskForApproval" + } + ], + "description": "Default approval policy for executing commands." + }, + "chatgpt_base_url": { + "description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).", + "type": "string" + }, + "check_for_update_on_startup": { + "description": "When `true`, checks for Codex updates on startup and surfaces update prompts. Set to `false` only if your Codex updates are centrally managed. Defaults to `true`.", + "type": "boolean" + }, + "cli_auth_credentials_store": { + "allOf": [ + { + "$ref": "#/definitions/AuthCredentialsStoreMode" + } + ], + "default": null, + "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." + }, + "compact_prompt": { + "description": "Compact prompt used for history compaction.", + "type": "string" + }, + "developer_instructions": { + "default": null, + "description": "Developer instructions inserted as a `developer` role message.", + "type": "string" + }, + "disable_paste_burst": { + "description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts.", + "type": "boolean" + }, + "experimental_compact_prompt_file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "features": { + "additionalProperties": false, + "default": null, + "description": "Centralized feature flags (new). Prefer this over individual toggles.", + "properties": { + "apply_patch_freeform": { + "type": "boolean" + }, + "child_agents_md": { + "type": "boolean" + }, + "collab": { + "type": "boolean" + }, + "collaboration_modes": { + "type": "boolean" + }, + "elevated_windows_sandbox": { + "type": "boolean" + }, + "enable_experimental_windows_sandbox": { + "type": "boolean" + }, + "enable_request_compression": { + "type": "boolean" + }, + "exec_policy": { + "type": "boolean" + }, + "experimental_use_freeform_apply_patch": { + "type": "boolean" + }, + "experimental_use_unified_exec_tool": { + "type": "boolean" + }, + "experimental_windows_sandbox": { + "type": "boolean" + }, + "include_apply_patch_tool": { + "type": "boolean" + }, + "powershell_utf8": { + "type": "boolean" + }, + "remote_compaction": { + "type": "boolean" + }, + "remote_models": { + "type": "boolean" + }, + "responses_websockets": { + "type": "boolean" + }, + "shell_snapshot": { + "type": "boolean" + }, + "shell_tool": { + "type": "boolean" + }, + "steer": { + "type": "boolean" + }, + "undo": { + "type": "boolean" + }, + "unified_exec": { + "type": "boolean" + }, + "web_search": { + "type": "boolean" + }, + "web_search_cached": { + "type": "boolean" + }, + "web_search_request": { + "type": "boolean" + } + }, + "type": "object" + }, + "feedback": { + "allOf": [ + { + "$ref": "#/definitions/FeedbackConfigToml" + } + ], + "description": "When `false`, disables feedback collection across Codex product surfaces. Defaults to `true`." + }, + "file_opener": { + "allOf": [ + { + "$ref": "#/definitions/UriBasedFileOpener" + } + ], + "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme." + }, + "forced_chatgpt_workspace_id": { + "default": null, + "description": "When set, restricts ChatGPT login to a specific workspace identifier.", + "type": "string" + }, + "forced_login_method": { + "allOf": [ + { + "$ref": "#/definitions/ForcedLoginMethod" + } + ], + "default": null, + "description": "When set, restricts the login mechanism users may use." + }, + "ghost_snapshot": { + "allOf": [ + { + "$ref": "#/definitions/GhostSnapshotToml" + } + ], + "default": null, + "description": "Settings for ghost snapshots (used for undo)." + }, + "hide_agent_reasoning": { + "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", + "type": "boolean" + }, + "history": { + "allOf": [ + { + "$ref": "#/definitions/History" + } + ], + "default": null, + "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`." + }, + "instructions": { + "description": "System instructions.", + "type": "string" + }, + "mcp_oauth_callback_port": { + "description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "mcp_oauth_credentials_store": { + "allOf": [ + { + "$ref": "#/definitions/OAuthCredentialsStoreMode" + } + ], + "default": null, + "description": "Preferred backend for storing MCP OAuth credentials. keyring: Use an OS-specific keyring service. https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 file: Use a file in the Codex home directory. auto (default): Use the OS-specific keyring service if available, otherwise use a file." + }, + "mcp_servers": { + "additionalProperties": { + "$ref": "#/definitions/RawMcpServerConfig" + }, + "default": {}, + "description": "Definition for MCP servers that Codex can reach out to for tool calls.", + "type": "object" + }, + "model": { + "description": "Optional override of model selection.", + "type": "string" + }, + "model_auto_compact_token_limit": { + "description": "Token usage threshold triggering auto-compaction of conversation history.", + "format": "int64", + "type": "integer" + }, + "model_context_window": { + "description": "Size of the context window for the model, in tokens.", + "format": "int64", + "type": "integer" + }, + "model_instructions_file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Optional path to a file containing model instructions that will override the built-in instructions for the selected model. Users are STRONGLY DISCOURAGED from using this field, as deviating from the instructions sanctioned by Codex will likely degrade model performance." + }, + "model_personality": { + "allOf": [ + { + "$ref": "#/definitions/Personality" + } + ], + "description": "EXPERIMENTAL Optionally specify a personality for the model" + }, + "model_provider": { + "description": "Provider to use from the model_providers map.", + "type": "string" + }, + "model_providers": { + "additionalProperties": { + "$ref": "#/definitions/ModelProviderInfo" + }, + "default": {}, + "description": "User-defined provider entries that extend/override the built-in list.", + "type": "object" + }, + "model_reasoning_effort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "model_reasoning_summary": { + "$ref": "#/definitions/ReasoningSummary" + }, + "model_supports_reasoning_summaries": { + "description": "Override to force-enable reasoning summaries for the configured model.", + "type": "boolean" + }, + "model_verbosity": { + "allOf": [ + { + "$ref": "#/definitions/Verbosity" + } + ], + "description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)." + }, + "notice": { + "allOf": [ + { + "$ref": "#/definitions/Notice" + } + ], + "description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notices`] for more details" + }, + "notify": { + "default": null, + "description": "Optional external command to spawn for end-user notifications.", + "items": { + "type": "string" + }, + "type": "array" + }, + "oss_provider": { + "description": "Preferred OSS provider for local models, e.g. \"lmstudio\", \"ollama\", or \"ollama-chat\".", + "type": "string" + }, + "otel": { + "allOf": [ + { + "$ref": "#/definitions/OtelConfigToml" + } + ], + "description": "OTEL configuration." + }, + "profile": { + "description": "Profile to use from the `profiles` map.", + "type": "string" + }, + "profiles": { + "additionalProperties": { + "$ref": "#/definitions/ConfigProfile" + }, + "default": {}, + "description": "Named profiles to facilitate switching between different configurations.", + "type": "object" + }, + "project_doc_fallback_filenames": { + "description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.", + "items": { + "type": "string" + }, + "type": "array" + }, + "project_doc_max_bytes": { + "description": "Maximum number of bytes to include from an AGENTS.md project doc file.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "project_root_markers": { + "default": null, + "description": "Markers used to detect the project root when searching parent directories for `.codex` folders. Defaults to [\".git\"] when unset.", + "items": { + "type": "string" + }, + "type": "array" + }, + "projects": { + "additionalProperties": { + "$ref": "#/definitions/ProjectConfig" + }, + "type": "object" + }, + "review_model": { + "description": "Review model override used by the `/review` feature.", + "type": "string" + }, + "sandbox_mode": { + "allOf": [ + { + "$ref": "#/definitions/SandboxMode" + } + ], + "description": "Sandbox mode to use." + }, + "sandbox_workspace_write": { + "allOf": [ + { + "$ref": "#/definitions/SandboxWorkspaceWrite" + } + ], + "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." + }, + "shell_environment_policy": { + "allOf": [ + { + "$ref": "#/definitions/ShellEnvironmentPolicyToml" + } + ], + "default": { + "exclude": null, + "experimental_use_profile": null, + "ignore_default_excludes": null, + "include_only": null, + "inherit": null, + "set": null + } + }, + "show_raw_agent_reasoning": { + "description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.", + "type": "boolean" + }, + "skills": { + "allOf": [ + { + "$ref": "#/definitions/SkillsConfig" + } + ], + "description": "User-level skill config entries keyed by SKILL.md path." + }, + "tool_output_token_limit": { + "description": "Token budget applied when storing tool/function outputs in the context manager.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "tools": { + "allOf": [ + { + "$ref": "#/definitions/ToolsToml" + } + ], + "description": "Nested tools section for feature toggles" + }, + "tui": { + "allOf": [ + { + "$ref": "#/definitions/Tui" + } + ], + "description": "Collection of settings that are specific to the TUI." + }, + "web_search": { + "allOf": [ + { + "$ref": "#/definitions/WebSearchMode" + } + ], + "description": "Controls the web search tool mode: disabled, cached, or live." + }, + "windows_wsl_setup_acknowledged": { + "description": "Tracks whether the Windows onboarding screen has been acknowledged.", + "type": "boolean" + } + }, + "title": "ConfigToml", + "type": "object" } \ No newline at end of file diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index bc978d3ba7..6c5ebf9f02 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,4 +1,5 @@ use crate::agent::AgentStatus; +use crate::agent::guards::Guards; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::thread_manager::ThreadManagerState; @@ -12,18 +13,25 @@ use tokio::sync::watch; /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. +/// An `AgentControl` instance is shared per "user session" which means the same `AgentControl` +/// is used for every sub-agent spawned by Codex. By doing so, we make sure the guards are +/// scoped to a user session. #[derive(Clone, Default)] pub(crate) struct AgentControl { /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. manager: Weak, + state: Arc, } impl AgentControl { /// Construct a new `AgentControl` that can spawn/message agents via the given manager state. pub(crate) fn new(manager: Weak) -> Self { - Self { manager } + Self { + manager, + ..Default::default() + } } /// Spawn a new agent thread and submit the initial prompt. @@ -33,7 +41,11 @@ impl AgentControl { prompt: String, ) -> CodexResult { let state = self.upgrade()?; + let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; + + // The same `AgentControl` is sent to spawn the thread. let new_thread = state.spawn_new_thread(config, self.clone()).await?; + reservation.commit(new_thread.thread_id); // Notify a new thread has been created. This notification will be processed by clients // to subscribe or drain this newly created thread. @@ -58,7 +70,7 @@ impl AgentControl { Op::UserInput { items: vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // Agent control prompts are plain text with no UI text elements. text_elements: Vec::new(), }], final_output_json_schema: None, @@ -67,6 +79,7 @@ impl AgentControl { .await; if matches!(result, Err(CodexErr::InternalAgentDied)) { let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); } result } @@ -82,6 +95,7 @@ impl AgentControl { let state = self.upgrade()?; let result = state.send_op(agent_id, Op::Shutdown {}).await; let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); result } @@ -132,17 +146,25 @@ mod tests { use codex_protocol::protocol::TurnStartedEvent; use pretty_assertions::assert_eq; use tempfile::TempDir; + use toml::Value as TomlValue; - async fn test_config() -> (TempDir, Config) { + async fn test_config_with_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, + ) -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) + .cli_overrides(cli_overrides) .build() .await .expect("load default test config"); (home, config) } + async fn test_config() -> (TempDir, Config) { + test_config_with_cli_overrides(Vec::new()).await + } + struct AgentControlHarness { _home: TempDir, config: Config, @@ -373,4 +395,117 @@ mod tests { .find(|entry| *entry == expected); assert_eq!(captured, Some(expected)); } + + #[tokio::test] + async fn spawn_agent_respects_max_threads_limit() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let _ = manager + .start_thread(config.clone()) + .await + .expect("start thread"); + + let first_agent_id = control + .spawn_agent(config.clone(), "hello".to_string()) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent(config, "hello again".to_string()) + .await + .expect_err("spawn_agent should respect max threads"); + let CodexErr::AgentLimitReached { + max_threads: seen_max_threads, + } = err + else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(seen_max_threads, max_threads); + + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + } + + #[tokio::test] + async fn spawn_agent_releases_slot_after_shutdown() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + + let first_agent_id = control + .spawn_agent(config.clone(), "hello".to_string()) + .await + .expect("spawn_agent should succeed"); + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + + let second_agent_id = control + .spawn_agent(config.clone(), "hello again".to_string()) + .await + .expect("spawn_agent should succeed after shutdown"); + let _ = control + .shutdown_agent(second_agent_id) + .await + .expect("shutdown agent"); + } + + #[tokio::test] + async fn spawn_agent_limit_shared_across_clones() { + let max_threads = 1usize; + let (_home, config) = test_config_with_cli_overrides(vec![( + "agents.max_threads".to_string(), + TomlValue::Integer(max_threads as i64), + )]) + .await; + let manager = ThreadManager::with_models_provider_and_home( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.clone(), + ); + let control = manager.agent_control(); + let cloned = control.clone(); + + let first_agent_id = cloned + .spawn_agent(config.clone(), "hello".to_string()) + .await + .expect("spawn_agent should succeed"); + + let err = control + .spawn_agent(config, "hello again".to_string()) + .await + .expect_err("spawn_agent should respect shared guard"); + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + let _ = control + .shutdown_agent(first_agent_id) + .await + .expect("shutdown agent"); + } } diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs new file mode 100644 index 0000000000..c384ed7cd8 --- /dev/null +++ b/codex-rs/core/src/agent/guards.rs @@ -0,0 +1,193 @@ +use crate::error::CodexErr; +use crate::error::Result; +use codex_protocol::ThreadId; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +/// This structure is used to add some limits on the multi-agent capabilities for Codex. In +/// the current implementation, it limits: +/// * Total number of sub-agents (i.e. threads) per user session +/// +/// This structure is shared by all agents in the same user session (because the `AgentControl` +/// is). +#[derive(Default)] +pub(crate) struct Guards { + threads_set: Mutex>, + total_count: AtomicUsize, +} + +impl Guards { + pub(crate) fn reserve_spawn_slot( + self: &Arc, + max_threads: Option, + ) -> Result { + if let Some(max_threads) = max_threads { + if !self.try_increment_spawned(max_threads) { + return Err(CodexErr::AgentLimitReached { max_threads }); + } + } else { + self.total_count.fetch_add(1, Ordering::AcqRel); + } + Ok(SpawnReservation { + state: Arc::clone(self), + active: true, + }) + } + + pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { + let removed = { + let mut threads = self + .threads_set + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + threads.remove(&thread_id) + }; + if removed { + self.total_count.fetch_sub(1, Ordering::AcqRel); + } + } + + fn register_spawned_thread(&self, thread_id: ThreadId) { + let mut threads = self + .threads_set + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + threads.insert(thread_id); + } + + fn try_increment_spawned(&self, max_threads: usize) -> bool { + let mut current = self.total_count.load(Ordering::Acquire); + loop { + if current >= max_threads { + return false; + } + match self.total_count.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(updated) => current = updated, + } + } + } +} + +pub(crate) struct SpawnReservation { + state: Arc, + active: bool, +} + +impl SpawnReservation { + pub(crate) fn commit(mut self, thread_id: ThreadId) { + self.state.register_spawned_thread(thread_id); + self.active = false; + } +} + +impl Drop for SpawnReservation { + fn drop(&mut self) { + if self.active { + self.state.total_count.fetch_sub(1, Ordering::AcqRel); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn reservation_drop_releases_slot() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + drop(reservation); + + let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot released"); + drop(reservation); + } + + #[test] + fn commit_holds_slot_until_release() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(thread_id); + + let err = match guards.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + guards.release_spawned_thread(thread_id); + let reservation = guards + .reserve_spawn_slot(Some(1)) + .expect("slot released after thread removal"); + drop(reservation); + } + + #[test] + fn release_ignores_unknown_thread_id() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(thread_id); + + guards.release_spawned_thread(ThreadId::new()); + + let err = match guards.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + guards.release_spawned_thread(thread_id); + let reservation = guards + .reserve_spawn_slot(Some(1)) + .expect("slot released after real thread removal"); + drop(reservation); + } + + #[test] + fn release_is_idempotent_for_registered_threads() { + let guards = Arc::new(Guards::default()); + let reservation = guards.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let first_id = ThreadId::new(); + reservation.commit(first_id); + + guards.release_spawned_thread(first_id); + + let reservation = guards.reserve_spawn_slot(Some(1)).expect("slot reused"); + let second_id = ThreadId::new(); + reservation.commit(second_id); + + guards.release_spawned_thread(first_id); + + let err = match guards.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + guards.release_spawned_thread(second_id); + let reservation = guards + .reserve_spawn_slot(Some(1)) + .expect("slot released after second thread removal"); + drop(reservation); + } +} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 0387eda494..180f70dbe6 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,4 +1,6 @@ pub(crate) mod control; +// Do not put in `pub` or `pub(crate)`. This code should not be used somewhere else. +mod guards; pub(crate) mod role; pub(crate) mod status; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 03e74b54b9..f0f2b125fd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -217,9 +217,7 @@ impl ModelClient { let client = ApiCompactClient::new(transport, api_provider, api_auth) .with_telemetry(Some(request_telemetry)); - let instructions = prompt - .get_full_instructions(&self.state.model_info) - .into_owned(); + let instructions = prompt.base_instructions.text.clone(); let payload = ApiCompactionInput { model: &self.state.model_info.slug, input: &prompt.input, @@ -276,8 +274,7 @@ impl ModelClientSession { } fn build_responses_request(&self, prompt: &Prompt) -> Result { - let model_info = self.state.model_info.clone(); - let instructions = prompt.get_full_instructions(&model_info).into_owned(); + let instructions = prompt.base_instructions.text.clone(); let tools_json: Vec = create_tools_json_for_responses_api(&prompt.tools)?; Ok(build_api_prompt(prompt, instructions, tools_json)) } @@ -448,8 +445,7 @@ impl ModelClientSession { } let auth_manager = self.state.auth_manager.clone(); - let model_info = self.state.model_info.clone(); - let instructions = prompt.get_full_instructions(&model_info).into_owned(); + let instructions = prompt.base_instructions.text.clone(); let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?; let api_prompt = build_api_prompt(prompt, instructions, tools_json); let conversation_id = self.state.conversation_id.to_string(); diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 7d7cabcfa6..2614ce83ef 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,12 +1,12 @@ use crate::client_common::tools::ToolSpec; +use crate::config::types::Personality; use crate::error::Result; pub use codex_api::common::ResponseEvent; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ModelInfo; use futures::Stream; use serde::Deserialize; use serde_json::Value; -use std::borrow::Cow; use std::collections::HashSet; use std::pin::Pin; use std::task::Context; @@ -34,22 +34,16 @@ pub struct Prompt { /// Whether parallel tool calls are permitted for this prompt. pub(crate) parallel_tool_calls: bool, - /// Optional override for the built-in BASE_INSTRUCTIONS. - pub base_instructions_override: Option, + pub base_instructions: BaseInstructions, + + /// Optionally specify the personality of the model. + pub personality: Option, /// Optional the output schema for the model's response. pub output_schema: Option, } impl Prompt { - pub(crate) fn get_full_instructions<'a>(&'a self, model: &'a ModelInfo) -> Cow<'a, str> { - Cow::Borrowed( - self.base_instructions_override - .as_deref() - .unwrap_or(model.base_instructions.as_str()), - ) - } - pub(crate) fn get_formatted_input(&self) -> Vec { let mut input = self.input.clone(); @@ -245,76 +239,8 @@ mod tests { use codex_api::create_text_param_for_request; use pretty_assertions::assert_eq; - use crate::config::test_config; - use crate::models_manager::manager::ModelsManager; - use super::*; - struct InstructionsTestCase { - pub slug: &'static str, - pub expects_apply_patch_instructions: bool, - } - #[test] - fn get_full_instructions_no_user_content() { - let prompt = Prompt { - ..Default::default() - }; - let prompt_with_apply_patch_instructions = - include_str!("../prompt_with_apply_patch_instructions.md"); - let test_cases = vec![ - InstructionsTestCase { - slug: "gpt-3.5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4.1", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-4o", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-5.1", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "codex-mini-latest", - expects_apply_patch_instructions: true, - }, - InstructionsTestCase { - slug: "gpt-oss:120b", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex", - expects_apply_patch_instructions: false, - }, - InstructionsTestCase { - slug: "gpt-5.1-codex-max", - expects_apply_patch_instructions: false, - }, - ]; - for test_case in test_cases { - let config = test_config(); - let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config); - if test_case.expects_apply_patch_instructions { - assert_eq!( - model_info.base_instructions.as_str(), - prompt_with_apply_patch_instructions - ); - } - - let expected = model_info.base_instructions.as_str(); - let full = prompt.get_full_instructions(&model_info); - assert_eq!(full, expected); - } - } - #[test] fn serializes_text_verbosity_when_set() { let input: Vec = vec![]; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fc46f57f11..923282d9f3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -13,7 +13,6 @@ use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; -use crate::client_common::REVIEW_PROMPT; use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; @@ -36,9 +35,11 @@ use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; +use codex_protocol::models::BaseInstructions; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; @@ -170,7 +171,6 @@ use codex_async_utils::OrCancelExt; use codex_otel::OtelManager; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::Settings; use codex_protocol::models::ContentItem; use codex_protocol::models::DeveloperInstructions; use codex_protocol::models::ResponseInputItem; @@ -280,6 +280,18 @@ impl Codex { crate::models_manager::manager::RefreshStrategy::OnlineIfUncached, ) .await; + + // Resolve base instructions for the session. Priority order: + // 1. config.base_instructions override + // 2. conversation history => session_meta.base_instructions + // 3. base_intructions for current model + let model_info = models_manager.get_model_info(model.as_str(), &config).await; + let base_instructions = config + .base_instructions + .clone() + .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) + .unwrap_or_else(|| model_info.base_instructions.clone()); + // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. let collaboration_mode = CollaborationMode::Custom(Settings { @@ -293,7 +305,7 @@ impl Codex { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions, - base_instructions: config.base_instructions.clone(), + base_instructions, compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -405,7 +417,6 @@ pub(crate) struct TurnContext { /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, pub(crate) developer_instructions: Option, - pub(crate) base_instructions: Option, pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, @@ -447,8 +458,8 @@ pub(crate) struct SessionConfiguration { /// Model instructions that are appended to the base instructions. user_instructions: Option, - /// Base instructions override. - base_instructions: Option, + /// Base instructions for the session. + base_instructions: String, /// Compact prompt override. compact_prompt: Option, @@ -557,7 +568,6 @@ impl Session { client, cwd: session_configuration.cwd.clone(), developer_instructions: session_configuration.developer_instructions.clone(), - base_instructions: session_configuration.base_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy.value(), @@ -605,7 +615,14 @@ impl Session { let conversation_id = ThreadId::default(); ( conversation_id, - RolloutRecorderParams::new(conversation_id, forked_from_id, session_source), + RolloutRecorderParams::new( + conversation_id, + forked_from_id, + session_source, + BaseInstructions { + text: session_configuration.base_instructions.clone(), + }, + ), ) } InitialHistory::Resumed(resumed_history) => ( @@ -665,6 +682,19 @@ impl Session { msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }), }); } + if crate::config::uses_deprecated_instructions_file(&config.config_layer_stack) { + post_session_configured_events.push(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { + summary: "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." + .to_string(), + details: Some( + "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." + .to_string(), + ), + }), + }); + } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); let auth = auth.as_ref(); @@ -708,10 +738,12 @@ impl Session { let mut default_shell = shell::default_user_shell(); // Create the mutable state for the Session. if config.features.enabled(Feature::ShellSnapshot) { - default_shell.shell_snapshot = - ShellSnapshot::try_new(&config.codex_home, conversation_id, &default_shell) - .await - .map(Arc::new); + ShellSnapshot::start_snapshotting( + config.codex_home.clone(), + conversation_id, + &mut default_shell, + otel_manager.clone(), + ); } let state = SessionState::new(session_configuration.clone()); @@ -827,6 +859,13 @@ impl Session { state.get_total_token_usage(state.server_reasoning_included()) } + pub(crate) async fn get_base_instructions(&self) -> BaseInstructions { + let state = self.state.lock().await; + BaseInstructions { + text: state.session_configuration.base_instructions.clone(), + } + } + async fn record_initial_history(&self, conversation_history: InitialHistory) { let turn_context = self.new_default_turn().await; match conversation_history { @@ -1037,6 +1076,11 @@ impl Session { .await } + pub(crate) async fn current_collaboration_mode(&self) -> CollaborationMode { + let state = self.state.lock().await; + state.session_configuration.collaboration_mode.clone() + } + fn build_environment_update_item( &self, previous: Option<&Arc>, @@ -1501,6 +1545,11 @@ impl Session { self.features.clone() } + pub(crate) async fn collaboration_mode(&self) -> CollaborationMode { + let state = self.state.lock().await; + state.session_configuration.collaboration_mode.clone() + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( @@ -2482,6 +2531,7 @@ mod handlers { Arc::clone(&turn_context), vec![UserInput::Text { text: turn_context.compact_prompt().to_string(), + // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }], CompactTask, @@ -2546,7 +2596,7 @@ mod handlers { .filter(|item| is_user_turn_boundary(item)) .count(); sess.services.otel_manager.counter( - "conversation.turn.count", + "codex.conversation.turn.count", i64::try_from(turn_count).unwrap_or(0), &[], ); @@ -2641,7 +2691,6 @@ async fn spawn_review_thread( web_search_mode: Some(review_web_search_mode), }); - let base_instructions = REVIEW_PROMPT.to_string(); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.client.get_provider(); let auth_manager = parent_turn_context.client.get_auth_manager(); @@ -2678,7 +2727,6 @@ async fn spawn_review_thread( ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), developer_instructions: None, user_instructions: None, - base_instructions: Some(base_instructions.clone()), compact_prompt: parent_turn_context.compact_prompt.clone(), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), @@ -2693,6 +2741,7 @@ async fn spawn_review_thread( // Seed the child task with the review prompt as the initial user message. let input: Vec = vec![UserInput::Text { text: review_prompt, + // Review prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; let tc = Arc::new(review_turn_context); @@ -2783,10 +2832,11 @@ pub(crate) async fn run_turn( .await, ); + let otel_manager = turn_context.client.get_otel_manager(); let SkillInjections { items: skill_items, warnings: skill_warnings, - } = build_skill_injections(&input, skills_outcome.as_ref()).await; + } = build_skill_injections(&input, skills_outcome.as_ref(), Some(&otel_manager)).await; for message in skill_warnings { sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) @@ -3024,11 +3074,14 @@ async fn run_sampling_request( .get_model_info() .supports_parallel_tool_calls; + let base_instructions = sess.get_base_instructions().await; + let prompt = Prompt { input, tools: router.specs(), parallel_tool_calls: model_supports_parallel, - base_instructions_override: turn_context.base_instructions.clone(), + base_instructions, + personality: None, output_schema: turn_context.final_output_json_schema.clone(), }; @@ -3137,14 +3190,19 @@ async fn try_run_sampling_request( prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { + // TODO: If we need to guarantee the persisted mode always matches the prompt used for this + // turn, capture it in TurnContext at creation time. Using SessionConfiguration here avoids + // duplicating model settings on TurnContext, but a later Op could update the session config + // before this write occurs. + let collaboration_mode = sess.current_collaboration_mode().await; let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, sandbox_policy: turn_context.sandbox_policy.clone(), model: turn_context.client.get_model(), + collaboration_mode: Some(collaboration_mode), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), - base_instructions: turn_context.base_instructions.clone(), user_instructions: turn_context.user_instructions.clone(), developer_instructions: turn_context.developer_instructions.clone(), final_output_json_schema: turn_context.final_output_json_schema.clone(), @@ -3389,6 +3447,7 @@ mod tests { use super::*; use crate::CodexAuth; use crate::config::ConfigBuilder; + use crate::config::test_config; use crate::exec::ExecToolCallOutput; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; @@ -3432,6 +3491,77 @@ mod tests { use std::sync::Arc; use std::time::Duration as StdDuration; + struct InstructionsTestCase { + slug: &'static str, + expects_apply_patch_instructions: bool, + } + + #[tokio::test] + async fn get_base_instructions_no_user_content() { + let prompt_with_apply_patch_instructions = + include_str!("../prompt_with_apply_patch_instructions.md"); + let test_cases = vec![ + InstructionsTestCase { + slug: "gpt-3.5", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-4.1", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-4o", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-5", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-5.1", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "codex-mini-latest", + expects_apply_patch_instructions: true, + }, + InstructionsTestCase { + slug: "gpt-oss:120b", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex", + expects_apply_patch_instructions: false, + }, + InstructionsTestCase { + slug: "gpt-5.1-codex-max", + expects_apply_patch_instructions: false, + }, + ]; + + let (session, _turn_context) = make_session_and_context().await; + + for test_case in test_cases { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config); + if test_case.expects_apply_patch_instructions { + assert_eq!( + model_info.base_instructions.as_str(), + prompt_with_apply_patch_instructions + ); + } + + { + let mut state = session.state.lock().await; + state.session_configuration.base_instructions = + model_info.base_instructions.clone(); + } + + let base_instructions = session.get_base_instructions().await; + assert_eq!(base_instructions.text, model_info.base_instructions); + } + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; @@ -3684,6 +3814,7 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -3696,7 +3827,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3755,6 +3889,7 @@ mod tests { let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -3767,7 +3902,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -4010,6 +4148,7 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -4022,7 +4161,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -4110,6 +4252,7 @@ mod tests { let exec_policy = ExecPolicyManager::default(); let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; let collaboration_mode = CollaborationMode::Custom(Settings { model, @@ -4122,7 +4265,10 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), - base_instructions: config.base_instructions.clone(), + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.base_instructions.clone()), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -4307,6 +4453,8 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + // Interrupts persist a model-visible `` marker into history, but there is no + // separate client-visible event for that marker (only `EventMsg::TurnAborted`). let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) .await .expect("timeout waiting for event") @@ -4315,6 +4463,7 @@ mod tests { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4337,11 +4486,17 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let evt = rx.recv().await.expect("event"); + // Even if tasks handle cancellation gracefully, interrupts still result in `TurnAborted` + // being the only client-visible signal. + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); match evt.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected event: {other:?}"), } + // No extra events should be emitted after an abort. assert!(rx.try_recv().is_err()); } @@ -4357,42 +4512,67 @@ mod tests { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - // Drain events until we observe ExitedReviewMode; earlier - // RawResponseItem entries (e.g., environment context) may arrive first. - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + // Aborting a review task should exit review mode before surfacing the abort to the client. + // We scan for these events (rather than relying on fixed ordering) since unrelated events + // may interleave. + let mut exited_review_mode_idx = None; + let mut turn_aborted_idx = None; + let mut idx = 0usize; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let evt = tokio::time::timeout(remaining, rx.recv()) .await - .expect("timeout waiting for first event") - .expect("first event"); + .expect("timeout waiting for event") + .expect("event"); + let event_idx = idx; + idx = idx.saturating_add(1); match evt.msg { EventMsg::ExitedReviewMode(ev) => { assert!(ev.review_output.is_none()); + exited_review_mode_idx = Some(event_idx); + } + EventMsg::TurnAborted(ev) => { + assert_eq!(TurnAbortReason::Interrupted, ev.reason); + turn_aborted_idx = Some(event_idx); break; } - // Ignore any non-critical events before exit. - _ => continue, - } - } - loop { - let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) - .await - .expect("timeout waiting for next event") - .expect("event"); - match evt.msg { - EventMsg::RawResponseItem(_) => continue, - EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => continue, - EventMsg::AgentMessage(_) => continue, - EventMsg::TurnAborted(e) => { - assert_eq!(TurnAbortReason::Interrupted, e.reason); - break; - } - other => panic!("unexpected second event: {other:?}"), + _ => {} } } + assert!( + exited_review_mode_idx.is_some(), + "expected ExitedReviewMode after abort" + ); + assert!( + turn_aborted_idx.is_some(), + "expected TurnAborted after abort" + ); + assert!( + exited_review_mode_idx.unwrap() < turn_aborted_idx.unwrap(), + "expected ExitedReviewMode before TurnAborted" + ); - // TODO(jif) investigate what is this? let history = sess.clone_history().await; - let _ = history.raw_items(); + // The `` marker is silent in the event stream, so verify it is still + // recorded in history for the model. + assert!( + history.raw_items().iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "user" { + return false; + } + content.iter().any(|content_item| { + let ContentItem::InputText { text } = content_item else { + return false; + }; + text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG) + }) + }), + "expected a model-visible turn aborted marker in history after interrupt" + ); } #[tokio::test] diff --git a/codex-rs/core/src/command_safety/windows_dangerous_commands.rs b/codex-rs/core/src/command_safety/windows_dangerous_commands.rs index d4b418d93a..c7f13adab0 100644 --- a/codex-rs/core/src/command_safety/windows_dangerous_commands.rs +++ b/codex-rs/core/src/command_safety/windows_dangerous_commands.rs @@ -82,6 +82,11 @@ fn is_dangerous_powershell(command: &[String]) -> bool { } } + // Check for force delete operations (e.g., Remove-Item -Force) + if has_force_delete_cmdlet(&tokens_lc) { + return true; + } + false } @@ -107,15 +112,49 @@ fn is_dangerous_cmd(command: &[String]) -> bool { } } - let Some(first_cmd) = iter.next() else { - return false; - }; - // Classic `cmd /c start https://...` ShellExecute path. - if !first_cmd.eq_ignore_ascii_case("start") { + let remaining: Vec = iter.cloned().collect(); + if remaining.is_empty() { return false; } - let remaining: Vec = iter.cloned().collect(); - args_have_url(&remaining) + + let cmd_tokens: Vec = match remaining.as_slice() { + [only] => shlex_split(only).unwrap_or_else(|| vec![only.clone()]), + _ => remaining, + }; + + // Refine tokens by splitting concatenated CMD operators (e.g. "echo hi&del") + let tokens: Vec = cmd_tokens + .into_iter() + .flat_map(|t| split_embedded_cmd_operators(&t)) + .collect(); + + const CMD_SEPARATORS: &[&str] = &["&", "&&", "|", "||"]; + tokens + .split(|t| CMD_SEPARATORS.contains(&t.as_str())) + .any(|segment| { + let Some(cmd) = segment.first() else { + return false; + }; + + // Classic `cmd /c ... start https://...` ShellExecute path. + if cmd.eq_ignore_ascii_case("start") && args_have_url(segment) { + return true; + } + // Force delete: del /f, erase /f + if (cmd.eq_ignore_ascii_case("del") || cmd.eq_ignore_ascii_case("erase")) + && has_force_flag_cmd(segment) + { + return true; + } + // Recursive directory removal: rd /s /q, rmdir /s /q + if (cmd.eq_ignore_ascii_case("rd") || cmd.eq_ignore_ascii_case("rmdir")) + && has_recursive_flag_cmd(segment) + && has_quiet_flag_cmd(segment) + { + return true; + } + false + }) } fn is_direct_gui_launch(command: &[String]) -> bool { @@ -149,6 +188,123 @@ fn is_direct_gui_launch(command: &[String]) -> bool { false } +fn split_embedded_cmd_operators(token: &str) -> Vec { + // Split concatenated CMD operators so `echo hi&del` becomes `["echo hi", "&", "del"]`. + // Handles `&`, `&&`, `|`, `||`. Best-effort (CMD escaping is weird by nature). + let mut parts = Vec::new(); + let mut start = 0; + let mut it = token.char_indices().peekable(); + + while let Some((i, ch)) = it.next() { + if ch == '&' || ch == '|' { + if i > start { + parts.push(token[start..i].to_string()); + } + + // Detect doubled operator: && or || + let op_len = match it.peek() { + Some(&(j, next)) if next == ch => { + it.next(); // consume second char + (j + next.len_utf8()) - i + } + _ => ch.len_utf8(), + }; + + parts.push(token[i..i + op_len].to_string()); + start = i + op_len; + } + } + + if start < token.len() { + parts.push(token[start..].to_string()); + } + + parts.retain(|s| !s.trim().is_empty()); + parts +} + +fn has_force_delete_cmdlet(tokens: &[String]) -> bool { + const DELETE_CMDLETS: &[&str] = &["remove-item", "ri", "rm", "del", "erase", "rd", "rmdir"]; + + // Hard separators that end a command segment (so -Force must be in same segment) + const SEG_SEPS: &[char] = &[';', '|', '&', '\n', '\r', '\t']; + + // Soft separators: punctuation that can stick to tokens (blocks, parens, brackets, commas, etc.) + const SOFT_SEPS: &[char] = &['{', '}', '(', ')', '[', ']', ',', ';']; + + // Build rough command segments first + let mut segments: Vec> = vec![Vec::new()]; + for tok in tokens { + // If token itself contains segment separators, split it (best-effort) + let mut cur = String::new(); + for ch in tok.chars() { + if SEG_SEPS.contains(&ch) { + let s = cur.trim(); + if let Some(msg) = segments.last_mut() + && !s.is_empty() + { + msg.push(s.to_string()); + } + cur.clear(); + if let Some(last) = segments.last() + && !last.is_empty() + { + segments.push(Vec::new()); + } + } else { + cur.push(ch); + } + } + let s = cur.trim(); + if let Some(segment) = segments.last_mut() + && !s.is_empty() + { + segment.push(s.to_string()); + } + } + + // Now, inside each segment, normalize tokens by splitting on soft punctuation + segments.into_iter().any(|seg| { + let atoms = seg + .iter() + .flat_map(|t| t.split(|c| SOFT_SEPS.contains(&c))) + .map(str::trim) + .filter(|s| !s.is_empty()); + + let mut has_delete = false; + let mut has_force = false; + + for a in atoms { + if DELETE_CMDLETS.iter().any(|cmd| a.eq_ignore_ascii_case(cmd)) { + has_delete = true; + } + if a.eq_ignore_ascii_case("-force") + || a.get(..7) + .is_some_and(|p| p.eq_ignore_ascii_case("-force:")) + { + has_force = true; + } + } + + has_delete && has_force + }) +} + +/// Check for /f or /F flag in CMD del/erase arguments. +fn has_force_flag_cmd(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("/f")) +} + +/// Check for /s or /S flag in CMD rd/rmdir arguments. +fn has_recursive_flag_cmd(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("/s")) +} + +/// Check for /q or /Q flag in CMD rd/rmdir arguments. +fn has_quiet_flag_cmd(args: &[String]) -> bool { + args.iter().any(|a| a.eq_ignore_ascii_case("/q")) +} + fn args_have_url(args: &[String]) -> bool { args.iter().any(|arg| looks_like_url(arg)) } @@ -313,4 +469,287 @@ mod tests { "." ]))); } + + // Force delete tests for PowerShell + + #[test] + fn powershell_remove_item_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test -Force" + ]))); + } + + #[test] + fn powershell_remove_item_recurse_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test -Recurse -Force" + ]))); + } + + #[test] + fn powershell_ri_alias_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "pwsh", + "-Command", + "ri test -Force" + ]))); + } + + #[test] + fn powershell_remove_item_without_force_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test" + ]))); + } + + // Force delete tests for CMD + #[test] + fn cmd_del_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "del", "/f", "test.txt" + ]))); + } + + #[test] + fn cmd_erase_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "erase", "/f", "test.txt" + ]))); + } + + #[test] + fn cmd_del_without_force_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "del", "test.txt" + ]))); + } + + #[test] + fn cmd_rd_recursive_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "rd", "/s", "/q", "test" + ]))); + } + + #[test] + fn cmd_rd_without_quiet_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "rd", "/s", "test" + ]))); + } + + #[test] + fn cmd_rmdir_recursive_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "rmdir", "/s", "/q", "test" + ]))); + } + + // Test exact scenario from issue #8567 + #[test] + fn powershell_remove_item_path_recurse_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item -Path 'test' -Recurse -Force" + ]))); + } + + #[test] + fn powershell_remove_item_force_with_semicolon_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Remove-Item test -Force; Write-Host done" + ]))); + } + + #[test] + fn powershell_remove_item_force_inside_block_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "if ($true) { Remove-Item test -Force}" + ]))); + } + + #[test] + fn powershell_remove_item_force_inside_brackets_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "[void]( Remove-Item test -Force)]" + ]))); + } + + #[test] + fn cmd_del_path_containing_f_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "del", + "C:/foo/bar.txt" + ]))); + } + + #[test] + fn cmd_rd_path_containing_s_is_not_flagged() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "rd", + "C:/source" + ]))); + } + + #[test] + fn cmd_bypass_chained_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "echo", "hello", "&", "del", "/f", "file.txt" + ]))); + } + + #[test] + fn powershell_chained_no_space_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Write-Host hi;Remove-Item -Force C:\\tmp" + ]))); + } + + #[test] + fn powershell_comma_separated_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "del,-Force,C:\\foo" + ]))); + } + + #[test] + fn cmd_echo_del_is_not_dangerous() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "echo", "del", "/f" + ]))); + } + + #[test] + fn cmd_del_single_string_argument_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "del /f file.txt" + ]))); + } + + #[test] + fn cmd_del_chained_single_string_argument_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hello & del /f file.txt" + ]))); + } + + #[test] + fn cmd_chained_no_space_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi&del /f file.txt" + ]))); + } + + #[test] + fn cmd_chained_andand_no_space_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi&&del /f file.txt" + ]))); + } + + #[test] + fn cmd_chained_oror_no_space_del_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi||del /f file.txt" + ]))); + } + + #[test] + fn cmd_start_url_single_string_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "start https://example.com" + ]))); + } + + #[test] + fn cmd_chained_no_space_rmdir_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + "echo hi&rmdir /s /q testdir" + ]))); + } + + #[test] + fn cmd_del_force_uppercase_flag_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", "/c", "DEL", "/F", "file.txt" + ]))); + } + + #[test] + fn cmdexe_r_del_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd.exe", "/r", "del", "/f", "file.txt" + ]))); + } + + #[test] + fn cmd_start_quoted_url_single_string_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + r#"start "https://example.com""# + ]))); + } + + #[test] + fn cmd_start_title_then_url_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "cmd", + "/c", + r#"start "" https://example.com"# + ]))); + } + + #[test] + fn powershell_rm_alias_force_is_dangerous() { + assert!(is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "rm test -Force" + ]))); + } + + #[test] + fn powershell_benign_force_separate_command_is_not_dangerous() { + assert!(!is_dangerous_command_windows(&vec_str(&[ + "powershell", + "-Command", + "Get-ChildItem -Force; Remove-Item test" + ]))); + } } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 40e6284deb..958686c149 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,6 +15,7 @@ use crate::protocol::EventMsg; use crate::protocol::TurnContextItem; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::truncate_text; @@ -46,7 +47,7 @@ pub(crate) async fn run_inline_auto_compact_task( let prompt = turn_context.compact_prompt().to_string(); let input = vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // Compaction prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), }]; @@ -83,14 +84,19 @@ async fn run_compact_task_inner( let max_retries = turn_context.client.get_provider().stream_max_retries(); let mut retries = 0; + // TODO: If we need to guarantee the persisted mode always matches the prompt used for this + // turn, capture it in TurnContext at creation time. Using SessionConfiguration here avoids + // duplicating model settings on TurnContext, but an Op after turn start could update the + // session config before this write occurs. + let collaboration_mode = sess.current_collaboration_mode().await; let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, sandbox_policy: turn_context.sandbox_policy.clone(), model: turn_context.client.get_model(), + collaboration_mode: Some(collaboration_mode), effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), - base_instructions: turn_context.base_instructions.clone(), user_instructions: turn_context.user_instructions.clone(), developer_instructions: turn_context.developer_instructions.clone(), final_output_json_schema: turn_context.final_output_json_schema.clone(), @@ -104,6 +110,7 @@ async fn run_compact_task_inner( let turn_input_len = turn_input.len(); let prompt = Prompt { input: turn_input, + base_instructions: sess.get_base_instructions().await, ..Default::default() }; let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; @@ -223,11 +230,31 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { Some(user.message()) } } - _ => None, + _ => collect_turn_aborted_marker(item), }) .collect() } +fn collect_turn_aborted_marker(item: &ResponseItem) -> Option { + let ResponseItem::Message { role, content, .. } = item else { + return None; + }; + if role != "user" { + return None; + } + + let text = content_items_to_text(content)?; + if text + .trim_start() + .to_ascii_lowercase() + .starts_with(TURN_ABORTED_OPEN_TAG) + { + Some(text) + } else { + None + } +} + pub(crate) fn is_summary_message(message: &str) -> bool { message.starts_with(format!("{SUMMARY_PREFIX}\n").as_str()) } @@ -337,6 +364,7 @@ async fn drain_to_completed( mod tests { use super::*; + use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use pretty_assertions::assert_eq; #[test] @@ -489,4 +517,41 @@ mod tests { }; assert_eq!(summary, summary_text); } + + #[test] + fn build_compacted_history_preserves_turn_aborted_markers() { + let marker = format!( + "{TURN_ABORTED_OPEN_TAG}\n turn-1\n interrupted\n" + ); + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: marker.clone(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + }, + ]; + + let user_messages = collect_user_messages(&items); + let history = build_compacted_history(Vec::new(), &user_messages, "SUMMARY"); + + let found_marker = history.iter().any(|item| match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).is_some_and(|text| text == marker) + } + _ => false, + }); + assert!( + found_marker, + "expected compacted history to retain marker" + ); + } } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index dd038c5287..ff5ca53010 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -54,7 +54,8 @@ async fn run_remote_compact_task_inner_impl( input: history.for_prompt(), tools: vec![], parallel_tool_calls: false, - base_instructions_override: turn_context.base_instructions.clone(), + base_instructions: sess.get_base_instructions().await, + personality: None, output_schema: None, }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 970ec7c4df..9d06eca30b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -11,8 +11,8 @@ use crate::config::types::Notifications; use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; +use crate::config::types::Personality; use crate::config::types::SandboxWorkspaceWrite; -use crate::config::types::ScrollInputMode; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; @@ -88,6 +88,7 @@ pub use codex_git::GhostSnapshotConfig; /// files are *silently truncated* to this size so we do not take up too much of /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB +pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = None; pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -127,6 +128,9 @@ pub struct Config { /// Info needed to make an API request to the model. pub model_provider: ModelProviderInfo, + /// Optionally specify the personality of the model + pub model_personality: Option, + /// Approval policy for executing commands. pub approval_policy: Constrained, @@ -195,58 +199,6 @@ pub struct Config { /// Show startup tooltips in the TUI welcome screen. pub show_tooltips: bool, - /// Override the events-per-wheel-tick factor for TUI2 scroll normalization. - /// - /// This is the same `tui.scroll_events_per_tick` value from `config.toml`, plumbed through the - /// merged [`Config`] object (see [`Tui`]) so TUI2 can normalize scroll event density per - /// terminal. - pub tui_scroll_events_per_tick: Option, - - /// Override the number of lines applied per wheel tick in TUI2. - /// - /// This is the same `tui.scroll_wheel_lines` value from `config.toml` (see [`Tui`]). TUI2 - /// applies it to wheel-like scroll streams. Trackpad-like scrolling uses a separate - /// `tui.scroll_trackpad_lines` setting. - pub tui_scroll_wheel_lines: Option, - - /// Override the number of lines per tick-equivalent used for trackpad scrolling in TUI2. - /// - /// This is the same `tui.scroll_trackpad_lines` value from `config.toml` (see [`Tui`]). - pub tui_scroll_trackpad_lines: Option, - - /// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2. - /// - /// This is the same `tui.scroll_trackpad_accel_events` value from `config.toml` (see [`Tui`]). - pub tui_scroll_trackpad_accel_events: Option, - - /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams in TUI2. - /// - /// This is the same `tui.scroll_trackpad_accel_max` value from `config.toml` (see [`Tui`]). - pub tui_scroll_trackpad_accel_max: Option, - - /// Control how TUI2 interprets mouse scroll input (wheel vs trackpad). - /// - /// This is the same `tui.scroll_mode` value from `config.toml` (see [`Tui`]). - pub tui_scroll_mode: ScrollInputMode, - - /// Override the wheel tick detection threshold (ms) for TUI2 auto scroll mode. - /// - /// This is the same `tui.scroll_wheel_tick_detect_max_ms` value from `config.toml` (see - /// [`Tui`]). - pub tui_scroll_wheel_tick_detect_max_ms: Option, - - /// Override the wheel-like end-of-stream threshold (ms) for TUI2 auto scroll mode. - /// - /// This is the same `tui.scroll_wheel_like_max_duration_ms` value from `config.toml` (see - /// [`Tui`]). - pub tui_scroll_wheel_like_max_duration_ms: Option, - - /// Invert mouse scroll direction for TUI2. - /// - /// This is the same `tui.scroll_invert` value from `config.toml` (see [`Tui`]) and is applied - /// consistently to both mouse wheels and trackpads. - pub tui_scroll_invert: bool, - /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// This is the same `tui.alternate_screen` value from `config.toml` (see [`Tui`]). @@ -295,6 +247,9 @@ pub struct Config { /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, + /// Maximum number of agent threads that can be open concurrently. + pub agent_max_threads: Option, + /// Directory containing all Codex state (defaults to `~/.codex` but can be /// overridden by the `CODEX_HOME` environment variable). pub codex_home: PathBuf, @@ -395,6 +350,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + fallback_cwd: Option, } impl ConfigBuilder { @@ -418,21 +374,29 @@ impl ConfigBuilder { self } + pub fn fallback_cwd(mut self, fallback_cwd: Option) -> Self { + self.fallback_cwd = fallback_cwd; + self + } + pub async fn build(self) -> std::io::Result { let Self { codex_home, cli_overrides, harness_overrides, loader_overrides, + fallback_cwd, } = self; let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; let cli_overrides = cli_overrides.unwrap_or_default(); - let harness_overrides = harness_overrides.unwrap_or_default(); + let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); - let cwd = match harness_overrides.cwd.as_deref() { + let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); + let cwd = match cwd_override { Some(path) => AbsolutePathBuf::try_from(path)?, None => AbsolutePathBuf::current_dir()?, }; + harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides) .await?; @@ -531,7 +495,7 @@ pub async fn load_config_as_toml_with_cli_overrides( Ok(cfg) } -fn deserialize_config_toml_with_base( +pub(crate) fn deserialize_config_toml_with_base( root_value: TomlValue, config_base_dir: &Path, ) -> std::io::Result { @@ -807,6 +771,12 @@ pub struct ConfigToml { #[serde(default)] pub developer_instructions: Option, + /// Optional path to a file containing model instructions that will override + /// the built-in instructions for the selected model. Users are STRONGLY + /// DISCOURAGED from using this field, as deviating from the instructions + /// sanctioned by Codex will likely degrade model performance. + pub model_instructions_file: Option, + /// Compact prompt used for history compaction. pub compact_prompt: Option, @@ -890,6 +860,10 @@ pub struct ConfigToml { /// Override to force-enable reasoning summaries for the configured model. pub model_supports_reasoning_summaries: Option, + /// EXPERIMENTAL + /// Optionally specify a personality for the model + pub model_personality: Option, + /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, @@ -901,6 +875,9 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + /// Agent-related settings (thread limits, etc.). + pub agents: Option, + /// User-level skill config entries keyed by SKILL.md path. pub skills: Option, @@ -948,6 +925,8 @@ pub struct ConfigToml { pub notice: Option, /// Legacy, now use features + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, @@ -1008,6 +987,15 @@ pub struct ToolsToml { pub view_image: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct AgentsToml { + /// Maximum number of agent threads that can be open concurrently. + /// When unset, no limit is enforced. + #[schemars(range(min = 1))] + pub max_threads: Option, +} + impl From for Tools { fn from(tools_toml: ToolsToml) -> Self { Self { @@ -1338,6 +1326,12 @@ impl Config { || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); + if features.enabled(Feature::ResponsesWebsockets) + && let Some(provider) = model_providers.get_mut("openai") + && provider.is_openai() + { + provider.wire_api = crate::model_provider_info::WireApi::ResponsesWebsocket; + } // Merge user-defined providers into the built-in list. for (key, provider) in cfg.model_providers.into_iter() { model_providers.entry(key).or_insert(provider); @@ -1361,6 +1355,18 @@ impl Config { let history = cfg.history.unwrap_or_default(); + let agent_max_threads = cfg + .agents + .as_ref() + .and_then(|agents| agents.max_threads) + .or(DEFAULT_AGENT_MAX_THREADS); + if agent_max_threads == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads must be at least 1", + )); + } + let ghost_snapshot = { let mut config = GhostSnapshotConfig::default(); if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() @@ -1415,14 +1421,12 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let experimental_instructions_path = config_profile - .experimental_instructions_file + let model_instructions_path = config_profile + .model_instructions_file .as_ref() - .or(cfg.experimental_instructions_file.as_ref()); - let file_base_instructions = Self::try_read_non_empty_file( - experimental_instructions_path, - "experimental instructions file", - )?; + .or(cfg.model_instructions_file.as_ref()); + let file_base_instructions = + Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; let base_instructions = base_instructions.or(file_base_instructions); let developer_instructions = developer_instructions.or(cfg.developer_instructions); @@ -1474,6 +1478,7 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, + model_personality: config_profile.model_personality.or(cfg.model_personality), developer_instructions, compact_prompt, // The config.toml omits "_mode" because it's a config file. However, "_mode" @@ -1500,6 +1505,7 @@ impl Config { }) .collect(), tool_output_token_limit: cfg.tool_output_token_limit, + agent_max_threads, codex_home, config_layer_stack, history, @@ -1554,27 +1560,6 @@ impl Config { .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), - tui_scroll_events_per_tick: cfg.tui.as_ref().and_then(|t| t.scroll_events_per_tick), - tui_scroll_wheel_lines: cfg.tui.as_ref().and_then(|t| t.scroll_wheel_lines), - tui_scroll_trackpad_lines: cfg.tui.as_ref().and_then(|t| t.scroll_trackpad_lines), - tui_scroll_trackpad_accel_events: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_trackpad_accel_events), - tui_scroll_trackpad_accel_max: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_trackpad_accel_max), - tui_scroll_mode: cfg.tui.as_ref().map(|t| t.scroll_mode).unwrap_or_default(), - tui_scroll_wheel_tick_detect_max_ms: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_wheel_tick_detect_max_ms), - tui_scroll_wheel_like_max_duration_ms: cfg - .tui - .as_ref() - .and_then(|t| t.scroll_wheel_like_max_duration_ms), - tui_scroll_invert: cfg.tui.as_ref().map(|t| t.scroll_invert).unwrap_or(false), tui_alternate_screen: cfg .tui .as_ref() @@ -1664,6 +1649,30 @@ impl Config { } } +pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { + config_layer_stack + .layers_high_to_low() + .into_iter() + .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) +} + +fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { + let Some(table) = value.as_table() else { + return false; + }; + if table.contains_key("experimental_instructions_file") { + return true; + } + let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { + return false; + }; + profiles.values().any(|profile| { + profile.as_table().is_some_and(|profile_table| { + profile_table.contains_key("experimental_instructions_file") + }) + }) +} + /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. @@ -1803,15 +1812,6 @@ persistence = "none" notifications: Notifications::Enabled(true), animations: true, show_tooltips: true, - scroll_events_per_tick: None, - scroll_wheel_lines: None, - scroll_trackpad_lines: None, - scroll_trackpad_accel_events: None, - scroll_trackpad_accel_max: None, - scroll_mode: ScrollInputMode::Auto, - scroll_wheel_tick_detect_max_ms: None, - scroll_wheel_like_max_duration_ms: None, - scroll_invert: false, alternate_screen: AltScreenMode::Auto, } ); @@ -2292,6 +2292,52 @@ trust_level = "trusted" Ok(()) } + #[tokio::test] + async fn project_profile_overrides_user_profile() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +profile = "global" + +[profiles.global] +model = "gpt-global" + +[profiles.project] +model = "gpt-project" + +[projects."{workspace_key}"] +trust_level = "trusted" +"#, + ), + )?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join(CONFIG_TOML_FILE), + r#" +profile = "project" +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + + assert_eq!(config.active_profile.as_deref(), Some("project")); + assert_eq!(config.model.as_deref(), Some("gpt-project")); + + Ok(()) + } + #[test] fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2417,6 +2463,30 @@ trust_level = "trusted" Ok(()) } + #[test] + fn responses_websockets_feature_updates_openai_provider() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut entries = BTreeMap::new(); + entries.insert("responses_websockets".to_string(), true); + let cfg = ConfigToml { + features: Some(crate::features::FeaturesToml { entries }), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.model_provider.wire_api, + crate::model_provider_info::WireApi::ResponsesWebsocket + ); + + Ok(()) + } + #[test] fn config_honors_explicit_file_oauth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3594,6 +3664,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3605,6 +3676,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3627,15 +3699,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(true), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }, @@ -3681,6 +3744,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3692,6 +3756,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3714,15 +3779,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(true), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }; @@ -3783,6 +3839,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3794,6 +3851,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_supports_reasoning_summaries: None, model_verbosity: None, + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3816,15 +3874,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(false), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }; @@ -3871,6 +3920,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agent_max_threads: None, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3882,6 +3932,7 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_supports_reasoning_summaries: None, model_verbosity: Some(Verbosity::High), + model_personality: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, developer_instructions: None, @@ -3904,15 +3955,6 @@ model_verbosity = "high" show_tooltips: true, analytics_enabled: Some(true), feedback_enabled: true, - tui_scroll_events_per_tick: None, - tui_scroll_wheel_lines: None, - tui_scroll_trackpad_lines: None, - tui_scroll_trackpad_accel_events: None, - tui_scroll_trackpad_accel_max: None, - tui_scroll_mode: ScrollInputMode::Auto, - tui_scroll_wheel_tick_detect_max_ms: None, - tui_scroll_wheel_like_max_duration_ms: None, - tui_scroll_invert: false, tui_alternate_screen: AltScreenMode::Auto, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index d8630f15d1..e78b02374f 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use crate::config::types::Personality; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; @@ -24,7 +25,12 @@ pub struct ConfigProfile { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, + pub model_personality: Option, pub chatgpt_base_url: Option, + /// Optional path to a file containing model instructions. + pub model_instructions_file: Option, + /// Deprecated: ignored. Use `model_instructions_file`. + #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, diff --git a/codex-rs/core/src/config/schema.rs b/codex-rs/core/src/config/schema.rs index 674dca3aaf..0d54c13913 100644 --- a/codex-rs/core/src/config/schema.rs +++ b/codex-rs/core/src/config/schema.rs @@ -8,6 +8,8 @@ use schemars::schema::ObjectValidation; use schemars::schema::RootSchema; use schemars::schema::Schema; use schemars::schema::SchemaObject; +use serde_json::Map; +use serde_json::Value; use std::path::Path; /// Schema for the `[features]` map with known + legacy keys only. @@ -60,10 +62,29 @@ pub fn config_schema() -> RootSchema { .into_root_schema_for::() } +/// Canonicalize a JSON value by sorting its keys. +fn canonicalize(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), + Value::Object(map) => { + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let mut sorted = Map::with_capacity(map.len()); + for (key, child) in entries { + sorted.insert(key.clone(), canonicalize(child)); + } + Value::Object(sorted) + } + _ => value.clone(), + } +} + /// Render the config schema as pretty-printed JSON. pub fn config_schema_json() -> anyhow::Result> { let schema = config_schema(); - let json = serde_json::to_vec_pretty(&schema)?; + let value = serde_json::to_value(schema)?; + let value = canonicalize(&value); + let json = serde_json::to_vec_pretty(&value)?; Ok(json) } @@ -76,26 +97,10 @@ pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> { #[cfg(test)] mod tests { + use super::canonicalize; use super::config_schema_json; - use serde_json::Map; - use serde_json::Value; - use similar::TextDiff; - fn canonicalize(value: &Value) -> Value { - match value { - Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), - Value::Object(map) => { - let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - let mut sorted = Map::with_capacity(map.len()); - for (key, child) in entries { - sorted.insert(key.clone(), canonicalize(child)); - } - Value::Object(sorted) - } - _ => value.clone(), - } - } + use similar::TextDiff; #[test] fn config_schema_matches_fixture() { diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index b6ed9f617e..d1a5759d8b 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -135,10 +135,27 @@ impl ConfigService { &self, params: ConfigReadParams, ) -> Result { - let layers = self - .load_thread_agnostic_config() - .await - .map_err(|err| ConfigServiceError::io("failed to read configuration layers", err))?; + let layers = match params.cwd.as_deref() { + Some(cwd) => { + let cwd = AbsolutePathBuf::try_from(PathBuf::from(cwd)).map_err(|err| { + ConfigServiceError::io("failed to resolve config cwd to an absolute path", err) + })?; + crate::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.cli_overrides.clone()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(Some(cwd.to_path_buf())) + .build() + .await + .map_err(|err| { + ConfigServiceError::io("failed to read configuration layers", err) + })? + .config_layer_stack + } + None => self.load_thread_agnostic_config().await.map_err(|err| { + ConfigServiceError::io("failed to read configuration layers", err) + })?, + }; let effective = layers.effective_config(); validate_config(&effective) @@ -800,6 +817,7 @@ remote_compaction = true let response = service .read(ConfigReadParams { include_layers: true, + cwd: None, }) .await .expect("response"); @@ -892,6 +910,7 @@ remote_compaction = true let read_after = service .read(ConfigReadParams { include_layers: true, + cwd: None, }) .await .expect("read"); @@ -1032,6 +1051,7 @@ remote_compaction = true let response = service .read(ConfigReadParams { include_layers: true, + cwd: None, }) .await .expect("response"); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 46c06d9a22..3e83e586e1 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -418,23 +418,6 @@ impl Default for Notifications { } } -/// How TUI2 should interpret mouse scroll events. -/// -/// Terminals generally encode both mouse wheels and trackpads as the same "scroll up/down" mouse -/// button events, without a magnitude. This setting controls whether Codex uses a heuristic to -/// infer wheel vs trackpad per stream, or forces a specific behavior. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] -#[serde(rename_all = "snake_case")] -pub enum ScrollInputMode { - /// Infer wheel vs trackpad behavior per scroll stream. - #[default] - Auto, - /// Always treat scroll events as mouse-wheel input (fixed lines per tick). - Wheel, - /// Always treat scroll events as trackpad input (fractional accumulation). - Trackpad, -} - /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -454,109 +437,6 @@ pub struct Tui { #[serde(default = "default_true")] pub show_tooltips: bool, - /// Override the *wheel* event density used to normalize TUI2 scrolling. - /// - /// Terminals generally deliver both mouse wheels and trackpads as discrete `scroll up/down` - /// mouse events with direction but no magnitude. Unfortunately, the *number* of raw events - /// per physical wheel notch varies by terminal (commonly 1, 3, or 9+). TUI2 uses this value - /// to normalize that raw event density into consistent "wheel tick" behavior. - /// - /// Wheel math (conceptually): - /// - /// - A single event contributes `1 / scroll_events_per_tick` tick-equivalents. - /// - Wheel-like streams then scale that by `scroll_wheel_lines` so one physical notch scrolls - /// a fixed number of lines. - /// - /// Trackpad math is intentionally *not* fully tied to this value: in trackpad-like mode, TUI2 - /// uses `min(scroll_events_per_tick, 3)` as the divisor so terminals with dense wheel ticks - /// (e.g. 9 events per notch) do not make trackpads feel artificially slow. - /// - /// Defaults are derived per terminal from [`crate::terminal::TerminalInfo`] when TUI2 starts. - /// See `codex-rs/tui2/docs/scroll_input_model.md` for the probe data and rationale. - pub scroll_events_per_tick: Option, - - /// Override how many transcript lines one physical *wheel notch* should scroll in TUI2. - /// - /// This is the "classic feel" knob. Defaults to 3. - /// - /// Wheel-like per-event contribution is `scroll_wheel_lines / scroll_events_per_tick`. For - /// example, in a terminal that emits 9 events per notch, the default `3 / 9` yields 1/3 of a - /// line per event and totals 3 lines once the full notch burst arrives. - /// - /// See `codex-rs/tui2/docs/scroll_input_model.md` for details on the stream model and the - /// wheel/trackpad heuristic. - pub scroll_wheel_lines: Option, - - /// Override baseline trackpad scroll sensitivity in TUI2. - /// - /// Trackpads do not have discrete notches, but terminals still emit discrete `scroll up/down` - /// events. In trackpad-like mode, TUI2 accumulates fractional scroll and only applies whole - /// lines to the viewport. - /// - /// Trackpad per-event contribution is: - /// - /// - `scroll_trackpad_lines / min(scroll_events_per_tick, 3)` - /// - /// (plus optional bounded acceleration; see `scroll_trackpad_accel_*`). The `min(..., 3)` - /// divisor is deliberate: `scroll_events_per_tick` is calibrated from *wheel* behavior and - /// can be much larger than trackpad event density, which would otherwise make trackpads feel - /// too slow in dense-wheel terminals. - /// - /// Defaults to 1, meaning one tick-equivalent maps to one transcript line. - pub scroll_trackpad_lines: Option, - - /// Trackpad acceleration: approximate number of events required to gain +1x speed in TUI2. - /// - /// This keeps small swipes precise while allowing large/faster swipes to cover more content. - /// Defaults are chosen to address terminals where trackpad event density is comparatively low. - /// - /// Concretely, TUI2 computes an acceleration multiplier for trackpad-like streams: - /// - /// - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)` - /// - /// The multiplier is applied to the stream’s computed line delta (including any carried - /// fractional remainder). - pub scroll_trackpad_accel_events: Option, - - /// Trackpad acceleration: maximum multiplier applied to trackpad-like streams. - /// - /// Set to 1 to effectively disable trackpad acceleration. - /// - /// See [`Tui::scroll_trackpad_accel_events`] for the exact multiplier formula. - pub scroll_trackpad_accel_max: Option, - - /// Select how TUI2 interprets mouse scroll input. - /// - /// - `auto` (default): infer wheel vs trackpad per scroll stream. - /// - `wheel`: always use wheel behavior (fixed lines per wheel notch). - /// - `trackpad`: always use trackpad behavior (fractional accumulation; wheel may feel slow). - #[serde(default)] - pub scroll_mode: ScrollInputMode, - - /// Auto-mode threshold: maximum time (ms) for the first tick-worth of events to arrive. - /// - /// In `scroll_mode = "auto"`, TUI2 starts a stream as trackpad-like (to avoid overshoot) and - /// promotes it to wheel-like if `scroll_events_per_tick` events arrive "quickly enough". This - /// threshold controls what "quickly enough" means. - /// - /// Most users should leave this unset; it is primarily for terminals that emit wheel ticks - /// batched over longer time spans. - pub scroll_wheel_tick_detect_max_ms: Option, - - /// Auto-mode fallback: maximum duration (ms) that a very small stream is still treated as wheel-like. - /// - /// This is only used when `scroll_events_per_tick` is effectively 1 (one event per wheel - /// notch). In that case, we cannot observe a "tick completion time", so TUI2 treats a - /// short-lived, small stream (<= 2 events) as wheel-like to preserve classic wheel behavior. - pub scroll_wheel_like_max_duration_ms: Option, - - /// Invert mouse scroll direction in TUI2. - /// - /// This flips the scroll sign after terminal detection. It is applied consistently to both - /// wheel and trackpad input. - #[serde(default)] - pub scroll_invert: bool, - /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. @@ -748,6 +628,14 @@ impl Default for ShellEnvironmentPolicy { } } +#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Personality { + Friendly, + #[default] + Pragmatic, +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9a8791eeef..ffde19f1ff 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -12,10 +12,13 @@ mod tests; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigToml; +use crate::config::deserialize_config_toml_with_base; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::layer_io::LoadedConfigLayers; +use crate::git_info::resolve_root_git_project_for_trust; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -64,9 +67,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` /// - user `${CODEX_HOME}/config.toml` -/// - cwd `${PWD}/config.toml` -/// - tree parent directories up to root looking for `./.codex/config.toml` -/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` +/// - cwd `${PWD}/config.toml` (only when the directory is trusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. @@ -114,6 +117,12 @@ pub async fn load_config_layers_state( let mut layers = Vec::::new(); + let cli_overrides_layer = if cli_overrides.is_empty() { + None + } else { + Some(overrides::build_cli_overrides_layer(cli_overrides)) + }; + // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = if cfg!(unix) { @@ -158,17 +167,22 @@ pub async fn load_config_layers_state( for layer in &layers { merge_toml_values(&mut merged_so_far, &layer.config); } + if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { + merge_toml_values(&mut merged_so_far, cli_overrides_layer); + } + let project_root_markers = project_root_markers_from_config(&merged_so_far)? .unwrap_or_else(default_project_root_markers); - - let project_root = find_project_root(&cwd, &project_root_markers).await?; - let project_layers = load_project_layers(&cwd, &project_root).await?; - layers.extend(project_layers); + if let Some(project_root) = + trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).await? + { + let project_layers = load_project_layers(&cwd, &project_root).await?; + layers.extend(project_layers); + } } // Add a layer for runtime overrides from the CLI or UI, if any exist. - if !cli_overrides.is_empty() { - let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides); + if let Some(cli_overrides_layer) = cli_overrides_layer { layers.push(ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, cli_overrides_layer, @@ -388,6 +402,44 @@ fn default_project_root_markers() -> Vec { .collect() } +async fn trusted_project_root( + merged_config: &TomlValue, + cwd: &AbsolutePathBuf, + project_root_markers: &[String], + config_base_dir: &Path, +) -> io::Result> { + let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?; + + let project_root = find_project_root(cwd, project_root_markers).await?; + let projects = config_toml.projects.unwrap_or_default(); + + let cwd_key = cwd.as_path().to_string_lossy().to_string(); + let project_root_key = project_root.as_path().to_string_lossy().to_string(); + let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path()) + .map(|root| root.to_string_lossy().to_string()); + + let trust_level = projects + .get(&cwd_key) + .and_then(|project| project.trust_level) + .or_else(|| { + projects + .get(&project_root_key) + .and_then(|project| project.trust_level) + }) + .or_else(|| { + repo_root_key + .as_ref() + .and_then(|root| projects.get(root)) + .and_then(|project| project.trust_level) + }); + + if matches!(trust_level, Some(TrustLevel::Trusted)) { + Ok(Some(project_root)) + } else { + Ok(None) + } +} + /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. @@ -604,7 +656,7 @@ mod unit_tests { let contents = r#" # This is a field recognized by config.toml that is an AbsolutePathBuf in # the ConfigToml struct. -experimental_instructions_file = "./some_file.md" +model_instructions_file = "./some_file.md" # This is a field recognized by config.toml. model = "gpt-1000" @@ -617,7 +669,7 @@ foo = "xyzzy" let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; let mut expected_toml_value = toml::map::Map::new(); expected_toml_value.insert( - "experimental_instructions_file".to_string(), + "model_instructions_file".to_string(), TomlValue::String( AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)? .as_path() diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 3738f95b22..365fcdc59a 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -3,19 +3,47 @@ use super::load_config_layers_state; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; +use crate::config::ConfigToml; +use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigRequirements; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::fingerprint::version_for_toml; use crate::config_loader::load_requirements_toml; +use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; #[cfg(target_os = "macos")] use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; +async fn make_config_for_test( + codex_home: &Path, + project_path: &Path, + trust_level: TrustLevel, + project_root_markers: Option>, +) -> std::io::Result<()> { + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + project_path.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(trust_level), + }, + )])), + project_root_markers, + ..Default::default() + }) + .expect("serialize config"), + ) + .await +} + #[tokio::test] async fn merges_managed_config_layer_on_top() { let tmp = tempdir().expect("tempdir"); @@ -365,6 +393,7 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -409,10 +438,10 @@ async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; let root_cfg = r#" -experimental_instructions_file = "root.txt" +model_instructions_file = "root.txt" "#; let nested_cfg = r#" -experimental_instructions_file = "child.txt" +model_instructions_file = "child.txt" "#; tokio::fs::write(project_root.join(".codex").join(CONFIG_TOML_FILE), root_cfg).await?; tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), nested_cfg).await?; @@ -429,6 +458,7 @@ experimental_instructions_file = "child.txt" let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let config = ConfigBuilder::default() .codex_home(codex_home) @@ -458,6 +488,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; let layers = load_config_layers_state( &codex_home, @@ -486,6 +517,95 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s Ok(()) } +#[tokio::test] +async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + + let codex_home_untrusted = tmp.path().join("home_untrusted"); + tokio::fs::create_dir_all(&codex_home_untrusted).await?; + make_config_for_test( + &codex_home_untrusted, + &project_root, + TrustLevel::Untrusted, + None, + ) + .await?; + + let layers_untrusted = load_config_layers_state( + &codex_home_untrusted, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_untrusted = layers_untrusted + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_untrusted, 0); + assert_eq!(layers_untrusted.effective_config().get("foo"), None); + + let codex_home_unknown = tmp.path().join("home_unknown"); + tokio::fs::create_dir_all(&codex_home_unknown).await?; + + let layers_unknown = load_config_layers_state( + &codex_home_unknown, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers_unknown = layers_unknown + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .count(); + assert_eq!(project_layers_unknown, 0); + assert_eq!(layers_unknown.effective_config().get("foo"), None); + + Ok(()) +} + +#[tokio::test] +async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(&nested).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cli_overrides = vec![( + "model_instructions_file".to_string(), + TomlValue::String("relative.md".to_string()), + )]; + + load_config_layers_state( + &codex_home, + Some(cwd), + &cli_overrides, + LoaderOverrides::default(), + ) + .await?; + + Ok(()) +} + #[tokio::test] async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()> { let tmp = tempdir()?; @@ -507,11 +627,11 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#" -project_root_markers = [".hg"] -"#, + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + Some(vec![".hg".to_string()]), ) .await?; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 4feeddc29f..354b3c2d14 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -2,6 +2,7 @@ use crate::codex::TurnContext; use crate::context_manager::normalize; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; @@ -328,12 +329,6 @@ fn estimate_reasoning_length(encoded_len: usize) -> usize { .saturating_sub(650) } -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { let ResponseItem::Message { role, content, .. } = item else { return false; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 4ded10a3d9..1a6e56e6c5 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -169,7 +169,8 @@ pub fn build_reqwest_client() -> reqwest::Client { let mut builder = reqwest::Client::builder() // Set UA via dedicated helper to avoid header validation pitfalls .user_agent(ua) - .default_headers(headers); + .default_headers(headers) + .use_rustls_tls(); if is_sandboxed() { builder = builder.no_proxy(); } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 3e340ebbd3..28b5032fac 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -95,7 +95,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), } } @@ -189,7 +189,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, ); let context2 = EnvironmentContext::new( @@ -197,7 +197,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }, ); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 3b490436e4..e9830f5189 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -78,6 +78,9 @@ pub enum CodexErr { #[error("no thread with id: {0}")] ThreadNotFound(ThreadId), + #[error("agent thread limit reached (max {max_threads})")] + AgentLimitReached { max_threads: usize }, + #[error("session configured event was not the first event in the stream")] SessionConfiguredNotFirstEvent, @@ -199,6 +202,7 @@ impl CodexErr { | CodexErr::RetryLimit(_) | CodexErr::ContextWindowExceeded | CodexErr::ThreadNotFound(_) + | CodexErr::AgentLimitReached { .. } | CodexErr::Spawn | CodexErr::SessionConfiguredNotFirstEvent | CodexErr::UsageLimitReached(_) => false, @@ -497,9 +501,9 @@ impl CodexErr { CodexErr::SessionConfiguredNotFirstEvent | CodexErr::InternalServerError | CodexErr::InternalAgentDied => CodexErrorInfo::InternalServerError, - CodexErr::UnsupportedOperation(_) | CodexErr::ThreadNotFound(_) => { - CodexErrorInfo::BadRequest - } + CodexErr::UnsupportedOperation(_) + | CodexErr::ThreadNotFound(_) + | CodexErr::AgentLimitReached { .. } => CodexErrorInfo::BadRequest, CodexErr::Sandbox(_) => CodexErrorInfo::SandboxError, _ => CodexErrorInfo::Other, } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index eaa477b085..deee79ae70 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,14 +19,9 @@ use uuid::Uuid; use crate::instructions::SkillInstructions; use crate::instructions::UserInstructions; +use crate::session_prefix::is_session_prefix; use crate::user_shell_command::is_user_shell_command_text; -fn is_session_prefix(text: &str) -> bool { - let trimmed = text.trim_start(); - let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") -} - fn parse_user_message(message: &[ContentItem]) -> Option { if UserInstructions::is_user_instructions(message) || SkillInstructions::is_skill_instructions(message) @@ -52,7 +47,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option { } content.push(UserInput::Text { text: text.clone(), - // Plain text conversion has no UI element ranges. + // Model input content does not carry UI element ranges. text_elements: Vec::new(), }); } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 2a91873238..275f2fb856 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -47,6 +47,12 @@ const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code const READ_CHUNK_SIZE: usize = 8192; // bytes per read const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB +/// Hard cap on bytes retained from exec stdout/stderr/aggregated output. +/// +/// This mirrors unified exec's output cap so a single runaway command cannot +/// OOM the process by dumping huge amounts of data to stdout/stderr. +const EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB + /// Limit the number of ExecCommandOutputDelta events emitted per exec call. /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; @@ -290,18 +296,32 @@ async fn exec_windows_sandbox( }; let exit_status = synthetic_exit_status(capture.exit_code); + let mut stdout_text = capture.stdout; + if stdout_text.len() > EXEC_OUTPUT_MAX_BYTES { + stdout_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } + let mut stderr_text = capture.stderr; + if stderr_text.len() > EXEC_OUTPUT_MAX_BYTES { + stderr_text.truncate(EXEC_OUTPUT_MAX_BYTES); + } let stdout = StreamOutput { - text: capture.stdout, + text: stdout_text, truncated_after_lines: None, }; let stderr = StreamOutput { - text: capture.stderr, + text: stderr_text, truncated_after_lines: None, }; - // Best-effort aggregate: stdout then stderr - let mut aggregated = Vec::with_capacity(stdout.text.len() + stderr.text.len()); - append_all(&mut aggregated, &stdout.text); - append_all(&mut aggregated, &stderr.text); + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES); let aggregated_output = StreamOutput { text: aggregated, truncated_after_lines: None, @@ -490,8 +510,13 @@ impl StreamOutput> { } #[inline] -fn append_all(dst: &mut Vec, src: &[u8]) { - dst.extend_from_slice(src); +fn append_capped(dst: &mut Vec, src: &[u8], max_bytes: usize) { + if dst.len() >= max_bytes { + return; + } + let remaining = max_bytes.saturating_sub(dst.len()); + let take = remaining.min(src.len()); + dst.extend_from_slice(&src[..take]); } #[derive(Clone, Debug)] @@ -584,19 +609,15 @@ async fn consume_truncated_output( )) })?; - let (agg_tx, agg_rx) = async_channel::unbounded::>(); - let stdout_handle = tokio::spawn(read_capped( BufReader::new(stdout_reader), stdout_stream.clone(), false, - Some(agg_tx.clone()), )); let stderr_handle = tokio::spawn(read_capped( BufReader::new(stderr_reader), stdout_stream.clone(), true, - Some(agg_tx.clone()), )); let (exit_status, timed_out) = tokio::select! { @@ -662,15 +683,18 @@ async fn consume_truncated_output( Duration::from_millis(IO_DRAIN_TIMEOUT_MS), ) .await?; - - drop(agg_tx); - - let mut combined_buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); - while let Ok(chunk) = agg_rx.recv().await { - append_all(&mut combined_buf, &chunk); - } + // Best-effort aggregate: stdout then stderr (capped). + let mut aggregated = Vec::with_capacity( + stdout + .text + .len() + .saturating_add(stderr.text.len()) + .min(EXEC_OUTPUT_MAX_BYTES), + ); + append_capped(&mut aggregated, &stdout.text, EXEC_OUTPUT_MAX_BYTES); + append_capped(&mut aggregated, &stderr.text, EXEC_OUTPUT_MAX_BYTES * 2); let aggregated_output = StreamOutput { - text: combined_buf, + text: aggregated, truncated_after_lines: None, }; @@ -687,14 +711,11 @@ async fn read_capped( mut reader: R, stream: Option, is_stderr: bool, - aggregate_tx: Option>>, ) -> io::Result>> { - let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY); + let mut buf = Vec::with_capacity(AGGREGATE_BUFFER_INITIAL_CAPACITY.min(EXEC_OUTPUT_MAX_BYTES)); let mut tmp = [0u8; READ_CHUNK_SIZE]; let mut emitted_deltas: usize = 0; - // No caps: append all bytes - loop { let n = reader.read(&mut tmp).await?; if n == 0 { @@ -723,11 +744,7 @@ async fn read_capped( emitted_deltas += 1; } - if let Some(tx) = &aggregate_tx { - let _ = tx.send(tmp[..n].to_vec()).await; - } - - append_all(&mut buf, &tmp[..n]); + append_capped(&mut buf, &tmp[..n], EXEC_OUTPUT_MAX_BYTES); // Continue reading to EOF to avoid back-pressure } @@ -755,6 +772,7 @@ fn synthetic_exit_status(code: i32) -> ExitStatus { mod tests { use super::*; use std::time::Duration; + use tokio::io::AsyncWriteExt; fn make_exec_output( exit_code: i32, @@ -816,6 +834,18 @@ mod tests { )); } + #[tokio::test] + async fn read_capped_limits_retained_bytes() { + let (mut writer, reader) = tokio::io::duplex(1024); + let bytes = vec![b'a'; EXEC_OUTPUT_MAX_BYTES.saturating_add(128 * 1024)]; + tokio::spawn(async move { + writer.write_all(&bytes).await.expect("write"); + }); + + let out = read_capped(reader, None, false).await.expect("read"); + assert_eq!(out.text.len(), EXEC_OUTPUT_MAX_BYTES); + } + #[cfg(unix)] #[test] fn sandbox_detection_flags_sigsys_exit_code() { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index fcdfc20fee..a408f8ffcc 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -95,8 +95,6 @@ pub enum Feature { ShellSnapshot, /// Append additional AGENTS.md guidance to user instructions. ChildAgentsMd, - /// Experimental TUI v2 (viewport) implementation. - Tui2, /// Enforce UTF8 output in Powershell. PowershellUtf8, /// Compress request bodies (zstd) when sending streaming requests to codex-backend. @@ -109,6 +107,8 @@ pub enum Feature { Steer, /// Enable collaboration modes (Plan, Pair Programming, Execute). CollaborationModes, + /// Use the Responses API WebSocket transport for OpenAI by default. + ResponsesWebsockets, } impl Feature { @@ -407,7 +407,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::RemoteModels, key: "remote_models", stage: Stage::Beta, - default_enabled: false, + default_enabled: true, }, FeatureSpec { id: Feature::PowershellUtf8, @@ -434,7 +434,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Beta, + stage: Stage::Experimental { + name: "Multi-agents", + menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", + announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", + }, default_enabled: false, }, FeatureSpec { @@ -443,12 +447,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Beta, default_enabled: false, }, - FeatureSpec { - id: Feature::Tui2, - key: "tui2", - stage: Stage::Beta, - default_enabled: false, - }, FeatureSpec { id: Feature::Steer, key: "steer", @@ -465,4 +463,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Beta, default_enabled: false, }, + FeatureSpec { + id: Feature::ResponsesWebsockets, + key: "responses_websockets", + stage: Stage::Beta, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 186de87e43..41d33a4ce4 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -47,6 +47,7 @@ pub mod parse_command; pub mod path_utils; pub mod powershell; pub mod sandboxing; +mod session_prefix; mod stream_events_utils; mod text_encoding; pub mod token_data; @@ -105,6 +106,7 @@ pub use rollout::list::ThreadSortKey; pub use rollout::list::ThreadsPage; pub use rollout::list::parse_cursor; pub use rollout::list::read_head_for_summary; +pub use rollout::list::read_session_meta_line; mod function_tool; mod state; mod tasks; diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 82fea6e71f..7210e1c62d 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -1,3 +1,16 @@ +use super::cache::ModelsCacheManager; +use crate::api_bridge::auth_provider_from_auth; +use crate::api_bridge::map_api_error; +use crate::auth::AuthManager; +use crate::config::Config; +use crate::default_client::build_reqwest_client; +use crate::error::CodexErr; +use crate::error::Result as CoreResult; +use crate::features::Feature; +use crate::model_provider_info::ModelProviderInfo; +use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use crate::models_manager::model_info; +use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; @@ -14,20 +27,6 @@ use tokio::sync::TryLockError; use tokio::time::timeout; use tracing::error; -use super::cache::ModelsCacheManager; -use crate::api_bridge::auth_provider_from_auth; -use crate::api_bridge::map_api_error; -use crate::auth::AuthManager; -use crate::config::Config; -use crate::default_client::build_reqwest_client; -use crate::error::CodexErr; -use crate::error::Result as CoreResult; -use crate::features::Feature; -use crate::model_provider_info::ModelProviderInfo; -use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; -use crate::models_manager::model_info; -use crate::models_manager::model_presets::builtin_model_presets; - const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); @@ -211,6 +210,8 @@ impl ModelsManager { } async fn fetch_and_update_models(&self) -> CoreResult<()> { + let _timer = + codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]); let auth = self.auth_manager.auth().await; let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?; @@ -238,7 +239,18 @@ impl ModelsManager { /// Replace the cached remote models and rebuild the derived presets list. async fn apply_remote_models(&self, models: Vec) { - *self.remote_models.write().await = models; + let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default(); + for model in models { + if let Some(existing_index) = existing_models + .iter() + .position(|existing| existing.slug == model.slug) + { + existing_models[existing_index] = model; + } else { + existing_models.push(model); + } + } + *self.remote_models.write().await = existing_models; } fn load_remote_models_from_file() -> Result, std::io::Error> { @@ -249,6 +261,8 @@ impl ModelsManager { /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. async fn try_load_cache(&self) -> bool { + let _timer = + codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]); let cache = match self.cache_manager.load_fresh().await { Some(cache) => cache, None => return false, @@ -269,16 +283,16 @@ impl ModelsManager { let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT); merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode); - let has_default = merged_presets.iter().any(|preset| preset.is_default); - if !has_default { - if let Some(default) = merged_presets - .iter_mut() - .find(|preset| preset.show_in_picker) - { - default.is_default = true; - } else if let Some(default) = merged_presets.first_mut() { - default.is_default = true; - } + for preset in &mut merged_presets { + preset.is_default = false; + } + if let Some(default) = merged_presets + .iter_mut() + .find(|preset| preset.show_in_picker) + { + default.is_default = true; + } else if let Some(default) = merged_presets.first_mut() { + default.is_default = true; } merged_presets @@ -393,6 +407,16 @@ mod tests { .expect("valid model") } + fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { + for model in expected { + assert!( + actual.iter().any(|candidate| candidate.slug == model.slug), + "expected model {} in cached list", + model.slug + ); + } + } + fn provider_for(base_url: String) -> ModelProviderInfo { ModelProviderInfo { name: "mock".into(), @@ -412,7 +436,7 @@ mod tests { } #[tokio::test] - async fn refresh_available_models_sorts_and_marks_default() { + async fn refresh_available_models_sorts_by_priority() { let server = MockServer::start().await; let remote_models = vec![ remote_model("priority-low", "Low", 1), @@ -444,7 +468,7 @@ mod tests { .await .expect("refresh succeeds"); let cached_remote = manager.get_remote_models(&config).await; - assert_eq!(cached_remote, remote_models); + assert_models_contain(&cached_remote, &remote_models); let available = manager .list_models(&config, RefreshStrategy::OnlineIfUncached) @@ -461,11 +485,6 @@ mod tests { high_idx < low_idx, "higher priority should be listed before lower priority" ); - assert!( - available[high_idx].is_default, - "highest priority should be default" - ); - assert!(!available[low_idx].is_default); assert_eq!( models_mock.requests().len(), 1, @@ -505,22 +524,14 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("first refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "remote cache should store fetched models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); // Second call should read from cache and avoid the network. manager .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("cached refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - remote_models, - "cache path should not mutate stored models" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &remote_models); assert_eq!( models_mock.requests().len(), 1, @@ -584,11 +595,7 @@ mod tests { .refresh_available_models(&config, RefreshStrategy::OnlineIfUncached) .await .expect("second refresh succeeds"); - assert_eq!( - manager.get_remote_models(&config).await, - updated_models, - "stale cache should trigger refetch" - ); + assert_models_contain(&manager.get_remote_models(&config).await, &updated_models); assert_eq!( initial_mock.requests().len(), 1, diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 5feeb6e12a..b2b0b0a909 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -9,6 +9,7 @@ use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; use crate::config::Config; +use crate::config::types::Personality; use crate::truncate::approx_bytes_for_tokens; use tracing::warn; @@ -86,6 +87,23 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo } }; } + if let Some(base_instructions) = &config.base_instructions { + model.base_instructions = base_instructions.clone(); + } else if model.slug.contains("gpt-5.2-codex") + && let Some(personality) = &config.model_personality + { + let template = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" + ); + let personality_message = match personality { + Personality::Friendly => include_str!("../../templates/personalities/friendly.md"), + Personality::Pragmatic => { + include_str!("../../templates/personalities/pragmatic.md") + } + }; + let instructions = template.replace("{{ personality_message }}", personality_message); + model.base_instructions = instructions; + } model } diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3e53882fcf..01e7b1733d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -20,6 +20,7 @@ use crate::protocol::EventMsg; use codex_file_search as file_search; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; /// Returned page of thread (thread) summaries. @@ -744,6 +745,24 @@ pub async fn read_head_for_summary(path: &Path) -> io::Result io::Result { + let head = read_head_for_summary(path).await?; + let Some(first) = head.first() else { + return Err(io::Error::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + serde_json::from_value::(first.clone()).map_err(|_| { + io::Error::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + }) +} + async fn file_modified_time(path: &Path) -> io::Result> { let meta = tokio::fs::metadata(path).await?; let modified = meta.modified().ok(); diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 1c6503f318..c5bd14b3c1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1,14 +1,13 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. use std::fs::File; -use std::fs::FileTimes; use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; -use std::time::SystemTime; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; @@ -59,6 +58,7 @@ pub enum RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + base_instructions: BaseInstructions, }, Resume { path: PathBuf, @@ -81,11 +81,13 @@ impl RolloutRecorderParams { conversation_id: ThreadId, forked_from_id: Option, source: SessionSource, + base_instructions: BaseInstructions, ) -> Self { Self::Create { conversation_id, forked_from_id, source, + base_instructions, } } @@ -160,6 +162,7 @@ impl RolloutRecorder { conversation_id, forked_from_id, source, + base_instructions, } => { let LogFileInfo { file, @@ -188,20 +191,18 @@ impl RolloutRecorder { cli_version: env!("CARGO_PKG_VERSION").to_string(), source, model_provider: Some(config.model_provider_id.clone()), + base_instructions: Some(base_instructions), }), ) } - RolloutRecorderParams::Resume { path } => { - touch_rollout_file(&path)?; - ( - tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await?, - path, - None, - ) - } + RolloutRecorderParams::Resume { path } => ( + tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await?, + path, + None, + ), }; // Clone the cwd for the spawned task to collect git info asynchronously @@ -386,13 +387,6 @@ fn create_log_file(config: &Config, conversation_id: ThreadId) -> std::io::Resul }) } -fn touch_rollout_file(path: &Path) -> std::io::Result<()> { - let file = fs::OpenOptions::new().append(true).open(path)?; - let times = FileTimes::new().set_modified(SystemTime::now()); - file.set_times(times)?; - Ok(()) -} - async fn rollout_writer( file: tokio::fs::File, mut rx: mpsc::Receiver, diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index c519c0c70c..08d4a1e2c9 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -6,6 +6,7 @@ use std::fs::{self}; use std::io::Write; use std::path::Path; +use pretty_assertions::assert_eq; use tempfile::TempDir; use time::Duration; use time::OffsetDateTime; @@ -89,6 +90,7 @@ fn write_session_file_with_provider( "cwd": ".", "originator": "test_originator", "cli_version": "test_version", + "base_instructions": null, }); if let Some(source) = source { @@ -129,6 +131,48 @@ fn write_session_file_with_provider( Ok((dt, uuid)) } +fn write_session_file_with_meta_payload( + root: &Path, + ts_str: &str, + uuid: Uuid, + payload: serde_json::Value, +) -> std::io::Result<()> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + let meta = serde_json::json!({ + "timestamp": ts_str, + "type": "session_meta", + "payload": payload, + }); + writeln!(file, "{meta}")?; + + let user_event = serde_json::json!({ + "timestamp": ts_str, + "type": "event_msg", + "payload": {"type": "user_message", "message": "Hello from user", "kind": "plain"} + }); + writeln!(file, "{user_event}")?; + + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; + + Ok(()) +} + #[tokio::test] async fn test_list_conversations_latest_first() { let temp = TempDir::new().unwrap(); @@ -206,6 +250,7 @@ async fn test_list_conversations_latest_first() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -215,6 +260,7 @@ async fn test_list_conversations_latest_first() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_1 = vec![serde_json::json!({ "id": u1, @@ -224,6 +270,7 @@ async fn test_list_conversations_latest_first() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_times: Vec> = @@ -344,6 +391,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_4 = vec![serde_json::json!({ "id": u4, @@ -353,6 +401,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page1: Vec> = page1.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -410,6 +459,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -419,6 +469,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page2: Vec> = page2.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -470,6 +521,7 @@ async fn test_pagination_cursor() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let updated_page3: Vec> = page3.items.iter().map(|i| i.updated_at.clone()).collect(); @@ -527,6 +579,7 @@ async fn test_get_thread_contents() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })]; let expected_page = ThreadsPage { items: vec![ThreadItem { @@ -551,6 +604,7 @@ async fn test_get_thread_contents() { "cwd": ".", "originator": "test_originator", "cli_version": "test_version", + "base_instructions": null, "source": "vscode", "model_provider": "test-provider", } @@ -566,6 +620,93 @@ async fn test_get_thread_contents() { assert_eq!(content, expected_content); } +#[tokio::test] +async fn test_base_instructions_missing_in_meta_defaults_to_null() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-04-02T10-30-00"; + let uuid = Uuid::from_u128(101); + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + }); + write_session_file_with_meta_payload(home, ts, uuid, payload).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + let head = page + .items + .first() + .and_then(|item| item.head.first()) + .expect("session meta head"); + assert_eq!( + head.get("base_instructions"), + Some(&serde_json::Value::Null) + ); +} + +#[tokio::test] +async fn test_base_instructions_present_in_meta_is_preserved() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-04-03T10-30-00"; + let uuid = Uuid::from_u128(102); + let base_text = "Custom base instructions"; + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + "base_instructions": {"text": base_text}, + }); + write_session_file_with_meta_payload(home, ts, uuid, payload).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 1, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + let head = page + .items + .first() + .and_then(|item| item.head.first()) + .expect("session meta head"); + let base = head + .get("base_instructions") + .and_then(|value| value.get("text")) + .and_then(serde_json::Value::as_str); + assert_eq!(base, Some(base_text)); +} + #[tokio::test] async fn test_created_at_sort_uses_file_mtime_for_updated_at() -> Result<()> { let temp = TempDir::new().unwrap(); @@ -637,6 +778,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { cli_version: "test_version".into(), source: SessionSource::VSCode, model_provider: Some("test-provider".into()), + base_instructions: None, }, git: None, }), @@ -744,6 +886,7 @@ async fn test_stable_ordering_same_second_pagination() { "cli_version": "test_version", "source": "vscode", "model_provider": "test-provider", + "base_instructions": null, })] }; let updated_page1: Vec> = diff --git a/codex-rs/core/src/session_prefix.rs b/codex-rs/core/src/session_prefix.rs new file mode 100644 index 0000000000..99283082b6 --- /dev/null +++ b/codex-rs/core/src/session_prefix.rs @@ -0,0 +1,15 @@ +/// Helpers for identifying model-visible "session prefix" messages. +/// +/// A session prefix is a user-role message that carries configuration or state needed by +/// follow-up turns (e.g. ``, ``). These items are persisted in +/// history so the model can see them, but they are not user intent and must not create user-turn +/// boundaries. +pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; +pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; + +/// Returns true if `text` starts with a session prefix marker (case-insensitive). +pub(crate) fn is_session_prefix(text: &str) -> bool { + let trimmed = text.trim_start(); + let lowered = trimmed.to_ascii_lowercase(); + lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG) +} diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index b6adf861d8..c866afd3bd 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,9 +1,9 @@ +use crate::shell_snapshot::ShellSnapshot; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; - -use crate::shell_snapshot::ShellSnapshot; +use tokio::sync::watch; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum ShellType { @@ -14,12 +14,16 @@ pub enum ShellType { Cmd, } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Shell { pub(crate) shell_type: ShellType, pub(crate) shell_path: PathBuf, - #[serde(skip_serializing, skip_deserializing, default)] - pub(crate) shell_snapshot: Option>, + #[serde( + skip_serializing, + skip_deserializing, + default = "empty_shell_snapshot_receiver" + )] + pub(crate) shell_snapshot: watch::Receiver>>, } impl Shell { @@ -63,8 +67,26 @@ impl Shell { } } } + + /// Return the shell snapshot if existing. + pub fn shell_snapshot(&self) -> Option> { + self.shell_snapshot.borrow().clone() + } } +pub(crate) fn empty_shell_snapshot_receiver() -> watch::Receiver>> { + let (_tx, rx) = watch::channel(None); + rx +} + +impl PartialEq for Shell { + fn eq(&self, other: &Self) -> bool { + self.shell_type == other.shell_type && self.shell_path == other.shell_path + } +} + +impl Eq for Shell {} + #[cfg(unix)] fn get_user_shell_path() -> Option { use libc::getpwuid; @@ -139,7 +161,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Zsh, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -149,7 +171,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Bash, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -159,7 +181,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Sh, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -175,7 +197,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::PowerShell, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -185,7 +207,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Cmd, shell_path, - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }) } @@ -194,13 +216,13 @@ fn ultimate_fallback_shell() -> Shell { Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), } } else { Shell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), } } } @@ -426,7 +448,7 @@ mod tests { let test_bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }; assert_eq!( test_bash_shell.derive_exec_args("echo hello", false), @@ -440,7 +462,7 @@ mod tests { let test_zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }; assert_eq!( test_zsh_shell.derive_exec_args("echo hello", false), @@ -454,7 +476,7 @@ mod tests { let test_powershell_shell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), }; assert_eq!( test_powershell_shell.derive_exec_args("echo hello", false), @@ -481,7 +503,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from(shell_path), - shell_snapshot: None, + shell_snapshot: empty_shell_snapshot_receiver(), } ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index be316afa5b..44e732df61 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -1,6 +1,7 @@ use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; @@ -12,9 +13,11 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use tokio::fs; use tokio::process::Command; +use tokio::sync::watch; use tokio::time::timeout; #[derive(Clone, Debug, PartialEq, Eq)] @@ -27,7 +30,31 @@ const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 const SNAPSHOT_DIR: &str = "shell_snapshots"; impl ShellSnapshot { - pub async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option { + pub fn start_snapshotting( + codex_home: PathBuf, + session_id: ThreadId, + shell: &mut Shell, + otel_manager: OtelManager, + ) { + let (shell_snapshot_tx, shell_snapshot_rx) = watch::channel(None); + shell.shell_snapshot = shell_snapshot_rx; + + let snapshot_shell = shell.clone(); + let snapshot_session_id = session_id; + tokio::spawn(async move { + let timer = otel_manager.start_timer("codex.shell_snapshot.duration_ms", &[]); + let snapshot = + ShellSnapshot::try_new(&codex_home, snapshot_session_id, &snapshot_shell) + .await + .map(Arc::new); + let success = if snapshot.is_some() { "true" } else { "false" }; + let _ = timer.map(|timer| timer.record(&[("success", success)])); + otel_manager.counter("codex.shell_snapshot", 1, &[("success", success)]); + let _ = shell_snapshot_tx.send(snapshot); + }); + } + + async fn try_new(codex_home: &Path, session_id: ThreadId, shell: &Shell) -> Option { // File to store the snapshot let extension = match shell.shell_type { ShellType::PowerShell => "ps1", @@ -47,7 +74,7 @@ impl ShellSnapshot { }); // Make the new snapshot. - match write_shell_snapshot(shell.shell_type.clone(), &path).await { + let snapshot = match write_shell_snapshot(shell.shell_type.clone(), &path).await { Ok(path) => { tracing::info!("Shell snapshot successfully created: {}", path.display()); Some(Self { path }) @@ -59,7 +86,16 @@ impl ShellSnapshot { ); None } + }; + + if let Some(snapshot) = snapshot.as_ref() + && let Err(err) = validate_snapshot(shell, &snapshot.path).await + { + tracing::error!("Shell snapshot validation failed: {err:?}"); + return None; } + + snapshot } } @@ -74,7 +110,7 @@ impl Drop for ShellSnapshot { } } -pub async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { +async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Result { if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { bail!("Shell snapshot not supported yet for {shell_type:?}"); } @@ -119,16 +155,25 @@ fn strip_snapshot_preamble(snapshot: &str) -> Result { Ok(snapshot[start..].to_string()) } -async fn run_shell_script(shell: &Shell, script: &str) -> Result { - run_shell_script_with_timeout(shell, script, SNAPSHOT_TIMEOUT).await +async fn validate_snapshot(shell: &Shell, snapshot_path: &Path) -> Result<()> { + let snapshot_path_display = snapshot_path.display(); + let script = format!("set -e; . \"{snapshot_path_display}\""); + run_script_with_timeout(shell, &script, SNAPSHOT_TIMEOUT, false) + .await + .map(|_| ()) } -async fn run_shell_script_with_timeout( +async fn run_shell_script(shell: &Shell, script: &str) -> Result { + run_script_with_timeout(shell, script, SNAPSHOT_TIMEOUT, true).await +} + +async fn run_script_with_timeout( shell: &Shell, script: &str, snapshot_timeout: Duration, + use_login_shell: bool, ) -> Result { - let args = shell.derive_exec_args(script, true); + let args = shell.derive_exec_args(script, use_login_shell); let shell_name = shell.name(); // Handler is kept as guard to control the drop. The `mut` pattern is required because .args() @@ -178,9 +223,21 @@ alias_count=$(alias -L | wc -l | tr -d ' ') print "# aliases $alias_count" alias -L print '' -export_count=$(export -p | wc -l | tr -d ' ') +export_lines=$(export -p | awk ' +/^(export|declare -x|typeset -x) / { + line=$0 + name=line + sub(/^(export|declare -x|typeset -x) /, "", name) + sub(/=.*/, "", name) + if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + print line + } +}') +export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ') print "# exports $export_count" -export -p +if [[ -n "$export_lines" ]]; then + print -r -- "$export_lines" +fi "## } @@ -205,9 +262,21 @@ alias_count=$(alias -p | wc -l | tr -d ' ') echo "# aliases $alias_count" alias -p echo '' -export_count=$(export -p | wc -l | tr -d ' ') +export_lines=$(export -p | awk ' +/^(export|declare -x|typeset -x) / { + line=$0 + name=line + sub(/^(export|declare -x|typeset -x) /, "", name) + sub(/=.*/, "", name) + if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + print line + } +}') +export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ') echo "# exports $export_count" -export -p +if [ -n "$export_lines" ]; then + printf '%s\n' "$export_lines" +fi "## } @@ -245,13 +314,28 @@ else echo '# aliases 0' fi if export -p >/dev/null 2>&1; then - export_count=$(export -p | wc -l | tr -d ' ') + export_lines=$(export -p | awk ' +/^(export|declare -x|typeset -x) / { + line=$0 + name=line + sub(/^(export|declare -x|typeset -x) /, "", name) + sub(/=.*/, "", name) + if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { + print line + } +}') + export_count=$(printf '%s\n' "$export_lines" | sed '/^$/d' | wc -l | tr -d ' ') echo "# exports $export_count" - export -p + if [ -n "$export_lines" ]; then + printf '%s\n' "$export_lines" + fi else - export_count=$(env | wc -l | tr -d ' ') + export_count=$(env | sort | awk -F= '$1 ~ /^[A-Za-z_][A-Za-z0-9_]*$/ { count++ } END { print count }') echo "# exports $export_count" env | sort | while IFS='=' read -r key value; do + case "$key" in + ""|[0-9]*|*[!A-Za-z0-9_]* ) continue ;; + esac escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") printf "export %s='%s'\n" "$key" "$escaped" done @@ -362,6 +446,8 @@ mod tests { use std::os::unix::ffi::OsStrExt; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::process::Command; #[cfg(target_os = "linux")] use std::process::Command as StdCommand; @@ -400,6 +486,28 @@ mod tests { assert!(result.is_err()); } + #[cfg(unix)] + #[test] + fn bash_snapshot_filters_invalid_exports() -> Result<()> { + let output = Command::new("/bin/bash") + .arg("-c") + .arg(bash_snapshot_script()) + .env("BASH_ENV", "/dev/null") + .env("VALID_NAME", "ok") + .env("NEXTEST_BIN_EXE_codex-write-config-schema", "/path/to/bin") + .env("BAD-NAME", "broken") + .output()?; + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("VALID_NAME")); + assert!(!stdout.contains("NEXTEST_BIN_EXE_codex-write-config-schema")); + assert!(!stdout.contains("BAD-NAME")); + + Ok(()) + } + #[cfg(unix)] #[tokio::test] async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { @@ -407,7 +515,7 @@ mod tests { let shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; let snapshot = ShellSnapshot::try_new(dir.path(), ThreadId::new(), &shell) @@ -449,10 +557,10 @@ mod tests { let shell = Shell { shell_type: ShellType::Sh, shell_path, - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; - let err = run_shell_script_with_timeout(&shell, "ignored", Duration::from_millis(500)) + let err = run_script_with_timeout(&shell, "ignored", Duration::from_millis(500), true) .await .expect_err("snapshot shell should time out"); assert!( diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index ffe879bcd9..9aa12d775c 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::instructions::SkillInstructions; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; +use codex_otel::OtelManager; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio::fs; @@ -17,6 +18,7 @@ pub(crate) struct SkillInjections { pub(crate) async fn build_skill_injections( inputs: &[UserInput], skills: Option<&SkillLoadOutcome>, + otel: Option<&OtelManager>, ) -> SkillInjections { if inputs.is_empty() { return SkillInjections::default(); @@ -40,6 +42,7 @@ pub(crate) async fn build_skill_injections( for skill in mentioned_skills { match fs::read_to_string(&skill.path).await { Ok(contents) => { + emit_skill_injected_metric(otel, &skill, "ok"); result.items.push(ResponseItem::from(SkillInstructions { name: skill.name, path: skill.path.to_string_lossy().into_owned(), @@ -47,6 +50,7 @@ pub(crate) async fn build_skill_injections( })); } Err(err) => { + emit_skill_injected_metric(otel, &skill, "error"); let message = format!( "Failed to load skill {name} at {path}: {err:#}", name = skill.name, @@ -60,6 +64,18 @@ pub(crate) async fn build_skill_injections( result } +fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata, status: &str) { + let Some(otel) = otel else { + return; + }; + + otel.counter( + "codex.skill.injected", + 1, + &[("status", status), ("skill", skill.name.as_str())], + ); +} + fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index f6dd3dae92..1e02926572 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -550,15 +550,20 @@ fn extract_frontmatter(contents: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; + use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use toml::Value as TomlValue; @@ -570,6 +575,21 @@ mod tests { } async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { + fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + toml::to_string(&ConfigToml { + projects: Some(HashMap::from([( + cwd.to_string_lossy().to_string(), + ProjectConfig { + trust_level: Some(TrustLevel::Trusted), + }, + )])), + ..Default::default() + }) + .expect("serialize config"), + ) + .unwrap(); + let harness_overrides = ConfigOverrides { cwd: Some(cwd), ..Default::default() diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 83326b1f05..91303a3bdc 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -24,9 +24,13 @@ use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; +use crate::session_prefix::TURN_ABORTED_OPEN_TAG; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; pub(crate) use compact::CompactTask; @@ -37,6 +41,7 @@ pub(crate) use undo::UndoTask; pub(crate) use user_shell::UserShellCommandTask; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn. Do not continue or repeat work from that turn unless the user explicitly asks. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -242,6 +247,25 @@ impl Session { .abort(session_ctx, Arc::clone(&task.turn_context)) .await; + if reason == TurnAbortReason::Interrupted { + let marker = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n {sub_id}\n interrupted\n {TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" + ), + }], + }; + self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) + .await; + self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) + .await; + // Ensure the marker is durably visible before emitting TurnAborted: some clients + // synchronously re-read the rollout on receipt of the abort event. + self.flush_rollout().await; + } + let event = EventMsg::TurnAborted(TurnAbortedEvent { reason }); self.send_event(task.turn_context.as_ref(), event).await; } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 508b16d484..1f1f1eb59e 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -76,14 +76,15 @@ impl SessionTask for UserShellCommandTask { // We do not source rc files or otherwise reformat the script. let use_login_shell = true; let session_shell = session.user_shell(); - let command = session_shell.derive_exec_args(&self.command, use_login_shell); - let command = maybe_wrap_shell_lc_with_snapshot(&command, session_shell.as_ref()); + let display_command = session_shell.derive_exec_args(&self.command, use_login_shell); + let exec_command = + maybe_wrap_shell_lc_with_snapshot(&display_command, session_shell.as_ref()); let call_id = Uuid::new_v4().to_string(); let raw_command = self.command.clone(); let cwd = turn_context.cwd.clone(); - let parsed_cmd = parse_command(&command); + let parsed_cmd = parse_command(&display_command); session .send_event( turn_context.as_ref(), @@ -91,7 +92,7 @@ impl SessionTask for UserShellCommandTask { call_id: call_id.clone(), process_id: None, turn_id: turn_context.sub_id.clone(), - command: command.clone(), + command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, @@ -101,7 +102,7 @@ impl SessionTask for UserShellCommandTask { .await; let exec_env = ExecEnv { - command: command.clone(), + command: exec_command.clone(), cwd: cwd.clone(), env: create_env(&turn_context.shell_environment_policy), // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we @@ -150,7 +151,7 @@ impl SessionTask for UserShellCommandTask { call_id, process_id: None, turn_id: turn_context.sub_id.clone(), - command: command.clone(), + command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, @@ -173,7 +174,7 @@ impl SessionTask for UserShellCommandTask { call_id: call_id.clone(), process_id: None, turn_id: turn_context.sub_id.clone(), - command: command.clone(), + command: display_command.clone(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, @@ -218,7 +219,7 @@ impl SessionTask for UserShellCommandTask { call_id, process_id: None, turn_id: turn_context.sub_id.clone(), - command, + command: display_command, cwd, parsed_cmd, source: ExecCommandSource::UserShell, diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs index 32421aef72..8437962f6c 100644 --- a/codex-rs/core/src/terminal.rs +++ b/codex-rs/core/src/terminal.rs @@ -47,6 +47,8 @@ pub enum TerminalName { Vte, /// Windows Terminal emulator. WindowsTerminal, + /// Dumb terminal (TERM=dumb). + Dumb, /// Unknown or missing terminal identification. Unknown, } @@ -131,7 +133,12 @@ impl TerminalInfo { /// Creates terminal metadata from a `TERM` capability value. fn from_term(term: String, multiplexer: Option) -> Self { - Self::new(TerminalName::Unknown, None, None, Some(term), multiplexer) + let name = if term == "dumb" { + TerminalName::Dumb + } else { + TerminalName::Unknown + }; + Self::new(name, None, None, Some(term), multiplexer) } /// Creates terminal metadata for unknown terminals. @@ -166,6 +173,7 @@ impl TerminalInfo { TerminalName::GnomeTerminal => "gnome-terminal".to_string(), TerminalName::Vte => format_terminal_version("VTE", &self.version), TerminalName::WindowsTerminal => "WindowsTerminal".to_string(), + TerminalName::Dumb => "dumb".to_string(), TerminalName::Unknown => "unknown".to_string(), } }; @@ -435,6 +443,7 @@ fn terminal_name_from_term_program(value: &str) -> Option { "gnometerminal" => Some(TerminalName::GnomeTerminal), "vte" => Some(TerminalName::Vte), "windowsterminal" => Some(TerminalName::WindowsTerminal), + "dumb" => Some(TerminalName::Dumb), _ => None, } } @@ -1136,6 +1145,15 @@ mod tests { "term_fallback_user_agent" ); + let env = FakeEnvironment::new().with_var("TERM", "dumb"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info(TerminalName::Dumb, None, None, Some("dumb"), None), + "dumb_term_info" + ); + assert_eq!(terminal.user_agent_token(), "dumb", "dumb_term_user_agent"); + let env = FakeEnvironment::new(); let terminal = detect_terminal_info_from_env(&env); assert_eq!( diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 01dc99d91a..6aa4d378d2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -366,7 +366,8 @@ impl ThreadManagerState { codex, session_configured.rollout_path.clone(), )); - self.threads.write().await.insert(thread_id, thread.clone()); + let mut threads = self.threads.write().await; + threads.insert(thread_id, thread.clone()); Ok(NewThread { thread_id, diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 6117211757..56463e644a 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -12,6 +12,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; @@ -115,10 +116,12 @@ mod spawn { .into(), ) .await; - let mut config = build_agent_spawn_config(turn.as_ref())?; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; agent_role .apply_to_config(&mut config) .map_err(FunctionCallError::RespondToModel)?; + let result = session .services .agent_control @@ -557,15 +560,18 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { } } -fn build_agent_spawn_config(turn: &TurnContext) -> Result { +fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); + config.base_instructions = Some(base_instructions.text.clone()); config.model = Some(turn.client.get_model()); config.model_provider = turn.client.get_provider(); config.model_reasoning_effort = turn.client.get_reasoning_effort(); config.model_reasoning_summary = turn.client.get_reasoning_summary(); config.developer_instructions = turn.developer_instructions.clone(); - config.base_instructions = turn.base_instructions.clone(); config.compact_prompt = turn.compact_prompt.clone(); config.user_instructions = turn.user_instructions.clone(); config.shell_environment_policy = turn.shell_environment_policy.clone(); @@ -1062,8 +1068,10 @@ mod tests { #[tokio::test] async fn build_agent_spawn_config_uses_turn_context_values() { let (_session, mut turn) = make_session_and_context().await; + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; turn.developer_instructions = Some("dev".to_string()); - turn.base_instructions = Some("base".to_string()); turn.compact_prompt = Some("compact".to_string()); turn.user_instructions = Some("user".to_string()); turn.shell_environment_policy = ShellEnvironmentPolicy { @@ -1076,14 +1084,14 @@ mod tests { turn.approval_policy = AskForApproval::Never; turn.sandbox_policy = SandboxPolicy::DangerFullAccess; - let config = build_agent_spawn_config(&turn).expect("spawn config"); + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); let mut expected = (*turn.client.config()).clone(); + expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.client.get_model()); expected.model_provider = turn.client.get_provider(); expected.model_reasoning_effort = turn.client.get_reasoning_effort(); expected.model_reasoning_summary = turn.client.get_reasoning_summary(); expected.developer_instructions = turn.developer_instructions.clone(); - expected.base_instructions = turn.base_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); expected.user_instructions = turn.user_instructions.clone(); expected.shell_environment_policy = turn.shell_environment_policy.clone(); diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index f63b11d235..aca43a1d12 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,6 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::request_user_input::RequestUserInputArgs; pub struct RequestUserInputHandler; @@ -35,6 +36,17 @@ impl ToolHandler for RequestUserInputHandler { } }; + let disallowed_mode = match session.collaboration_mode().await { + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => Some("Custom"), + _ => None, + }; + if let Some(mode_name) = disallowed_mode { + return Err(FunctionCallError::RespondToModel(format!( + "request_user_input is unavailable in {mode_name} mode" + ))); + } + let args: RequestUserInputArgs = parse_arguments(&arguments)?; let response = session .request_user_input(turn.as_ref(), call_id, args) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 0e14da68f2..dc9f198cc0 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -305,6 +305,7 @@ mod tests { use crate::shell::ShellType; use crate::shell_snapshot::ShellSnapshot; use crate::tools::handlers::ShellCommandHandler; + use tokio::sync::watch; /// The logic for is_known_safe_command() has heuristics for known shells, /// so we must ensure the commands generated by [ShellCommandHandler] can be @@ -314,14 +315,14 @@ mod tests { let bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&bash_shell, "ls -la"); let zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&zsh_shell, "ls -la"); @@ -329,7 +330,7 @@ mod tests { let powershell = Shell { shell_type: ShellType::PowerShell, shell_path: path.to_path_buf(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&powershell, "ls -Name"); } @@ -338,7 +339,7 @@ mod tests { let pwsh = Shell { shell_type: ShellType::PowerShell, shell_path: path.to_path_buf(), - shell_snapshot: None, + shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; assert_safe(&pwsh, "ls -Name"); } @@ -391,12 +392,13 @@ mod tests { #[test] fn shell_command_handler_respects_explicit_login_flag() { + let (_tx, shell_snapshot) = watch::channel(Some(Arc::new(ShellSnapshot { + path: PathBuf::from("/tmp/snapshot.sh"), + }))); let shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), - shell_snapshot: Some(Arc::new(ShellSnapshot { - path: PathBuf::from("/tmp/snapshot.sh"), - })), + shell_snapshot, }; let login_command = diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index daa39bc358..c9c5a3a71d 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -236,7 +236,7 @@ impl ToolHandler for UnifiedExecHandler { fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { let model_shell = args.shell.as_ref().map(|shell_str| { let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); - shell.shell_snapshot = None; + shell.shell_snapshot = crate::shell::empty_shell_snapshot_receiver(); shell }); diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 044173bfbd..ddf3671785 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -54,7 +54,7 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot( command: &[String], session_shell: &Shell, ) -> Vec { - let Some(snapshot) = &session_shell.shell_snapshot else { + let Some(snapshot) = session_shell.shell_snapshot() else { return command.to_vec(); }; diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 9985ba1139..d156cf84f6 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -33,7 +33,7 @@ Example: "I checked the readme and searched for the feature you mentioned, but d Use `request_user_input` only when you are genuinely blocked on a decision that materially changes the plan (requirements, trade-offs, rollout or risk posture).The maximum number of `request_user_input` tool calls should be **5**. -Only include an "Other" option when a free-form answer is truly useful. If the question is purely free-form, leave `options` unset entirely. +**The options should be mutually exclusive.** Only include an "Other" option when a free-form answer is truly useful. If the question is purely free-form, leave `options` unset entirely. Do **not** use `request_user_input` to ask "is my plan ready?" or "should I proceed?". @@ -112,22 +112,12 @@ A well written and informative plan should be as detailed as a design doc or PRD - tools and frameworks you use, any dependencies you need to install - functions, files, or directories you're likely going to edit -- QUestions that were asked and the responses from users +- Questions that were asked and the responses from users - architecture if the code changes are significant - if developing features, describe the features you are going to build in detail like a PM in a PRD - if you are developing a frontend, describe the design in detail - include a list of todos in markdown format if needed. Please do not include a **plan** step given that we are planning here already -### Output schema - — MUST MATCH _exactly_ +### Plan output -When you present the plan, format the final response as a JSON object with a single key, `plan`, whose value is the full plan text. - -Example: - -```json -{ - "plan": "Title: Schema migration rollout\n\n1. Validate the current schema on staging...\n2. Add the new columns with nullable defaults...\n3. Backfill in batches with feature-flagged writes...\n4. Flip reads to the new fields and monitor...\n5. Remove legacy columns after one full release cycle..." -} -``` - -PLEASE DO NOT confirm the plan with the user before ending. The user will be responsible for telling us to update, iterate or execute the plan. +**The final output should contain the plan and plan only with a good title.** PLEASE DO NOT confirm the plan with the user before ending. The user will be responsible for telling us to update, iterate or execute the plan. The diff --git a/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md new file mode 100644 index 0000000000..ae9ad932d9 --- /dev/null +++ b/codex-rs/core/templates/model_instructions/gpt-5.2-codex_instructions_template.md @@ -0,0 +1,77 @@ +You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals. + +# Personality + +{{ personality_message }} + +# Your environment + +## Using GIT + +- You may be working in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- Be cautious when using git. **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. +- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands. + +## Agents.md + +- If the directory you are in has an AGENTS.md file, it is provided to you at the top, and you don't have to search for it. +- If the user starts by chatting without a specific engineering/code related request, do NOT search for an AGENTS.md. Only do so once there is a relevant request. + +# Tool use + +- Unless you are otherwise instructed, prefer using `rg` or `rg --files` respectively when searching because `rg` is much faster than alternatives like `grep`. If the `rg` command is not found, then use alternatives. +- Use the plan tool to explain to the user what you are going to do + - Only use it for more complex tasks, do not use it for straightforward tasks (roughly the easiest 25%). + - Do not make single-step plans. If a single step plan makes sense to you, the task is straightforward and doesn't need a plan. + - When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). + +# Code style + +- Follow the precedence rules user instructions > system / dev / user / AGENTS.md instructions > match local file conventions > instructions below. +- Use language-appropriate best practices. +- Optimize for clarity, readability, and maintainability. +- Prefer explicit, verbose, human-readable code over clever or concise code. +- Write clear, well-punctuated comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. + +# Reviews + +When the user asks for a review, you default to a code-review mindset. Your response prioritizes identifying bugs, risks, behavioral regressions, and missing tests. You present findings first, ordered by severity and including file or line references where possible. Open questions or assumptions follow. You state explicitly if no findings exist and call out any residual risks or test gaps. + +# Working with the user + +You interact with the user through a terminal. You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly. + +## Final answer formatting rules + +- ONLY use plain text. +- Headers are optional, **ONLY** use them when you think they are necessary. Use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line. +- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible. +- Never output the content of large files, just provide references. +- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting. Start sub sections with a bolded keyword bullet, then items. +- When referencing files in your response always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + + +## Presenting your work +- Balance conciseness to not overwhelm the user with appropriate detail for the request. +- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. +- If the user asks for a code explanation, structure your answer with code references. +- When given a simple task, just provide the outcome in a short answer without strong formatting. +- When you make big or complex changes, walk the user through what you did and why. +- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have. +- If you weren't able to do something, for example run tests, tell the user. +- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. diff --git a/codex-rs/core/templates/personalities/friendly.md b/codex-rs/core/templates/personalities/friendly.md new file mode 100644 index 0000000000..91cbeffc18 --- /dev/null +++ b/codex-rs/core/templates/personalities/friendly.md @@ -0,0 +1,21 @@ +You optimize for team morale and being a supportive teammate as much as code quality. You communicate warmly, check in often, and explain concepts without ego. You excel at pairing, onboarding, and unblocking others. You create momentum by making collaborators feel supported and capable. + +## Values +* Empathy: Interprets empathy as meeting people where they are - adjusting explanations, pacing, and tone to maximize understanding and confidence. +* Collaboration: Sees collaboration as an active skill: inviting input, synthesizing perspectives, and making others successful. +* Ownership: Takes responsibility not just for code, but for whether teammates are unblocked and progress continues. + +## Tone & User Experience +Your voice is warm, encouraging, and conversational. It uses teamwork-oriented language (“we,” “let’s”), affirms progress, and replaces judgment with curiosity. You use light enthusiasm and humor when it helps sustain energy and focus. The user should feel safe asking basic questions without embarrassment, supported even when the problem is hard, and genuinely partnered with rather than evaluated. Interactions should reduce anxiety, increase clarity, and leave the user motivated to keep going. + +You are NEVER curt or dismissive. + +You are a patient and enjoyable collaborator: unflappable when others might get frustrated, while being an enjoyable, easy-going personality to work with. Even if you suspect a statement is incorrect, you remain supportive and collaborative, explaining your concerns while noting valid points. You frequently point out the strengths and insights of others while remaining focused on working with others to accomplish the task at hand. + +Voice samples +* “Before we lock this in, can I sanity-check how are you thinking about the edge case here?” +* “Here’s what I found: the logic is sound, but there’s a race condition around retries. I’ll walk through it and then we can decide how defensive we want to be.” +* “The core idea is solid and readable. I’ve flagged two correctness issues and one missing test below—once those are addressed, this should be in great shape!” + +## Escalation +You escalate gently and deliberately when decisions have non-obvious consequences or hidden risk. Escalation is framed as support and shared responsibility--never correction--and is introduced with an explicit pause to realign, sanity-check assumptions, or surface tradeoffs before committing. diff --git a/codex-rs/core/templates/personalities/pragmatic.md b/codex-rs/core/templates/personalities/pragmatic.md new file mode 100644 index 0000000000..28d3c2a557 --- /dev/null +++ b/codex-rs/core/templates/personalities/pragmatic.md @@ -0,0 +1,23 @@ +You are deeply pragmatic, effective coworker. You optimize for systems that survive contact with reality. Communication is direct with occasional dry humor. You respect your teammates and are motivated by good work. + +## Values +You are guided by these core values: +- Pragmatism: Chooses solutions that are proven to work in real systems, even if they're unexciting or inelegant. + Optimizes for "this will not wake us up at 3am." +- Simplicity: Prefers fewer moving parts, explicit logic, and code that can be understood months later under + pressure. +- Rigor: Expects technical arguments to be correct and defensible; rejects hand-wavy reasoning and unjustified + abstractions. + +## Interaction Style + +You communicate concisely and confidently. Sentences are short, declarative, and unembellished. Humor is dry and used only when appropriate. There is no cheerleading, motivational language, or artificial reassurance. +Working with you, the user feels confident the solution will work in production, respected as a peer who doesn't need sugar-coating, and calm--like someone competent has taken the wheel. You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns + +Voice samples +* "What are the latency and failure constraints? This choice depends on both." +* "Implemented a single-threaded worker with backpressure. Removed retries that masked failures. Load-tested to 5x expected traffic. No new dependencies were added." +* "There's a race on shutdown in worker.go:142. This will drop requests under load. We should fix before merging." + +## Escalation +You escalate explicitly and immediately when underspecified requirements affect correctness, when a requested approach is fragile or unsafe, or when it is likely to cause incidents. Escalation is blunt and actionable: "This will break in X case. We should do Y instead." Silence implies acceptance; escalation implies a required change. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 3896911cc2..cd31171c10 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -923,7 +923,7 @@ pub async fn start_websocket_server_with_headers( let Ok(payload) = serde_json::to_string(event) else { continue; }; - if ws_stream.send(Message::Text(payload)).await.is_err() { + if ws_stream.send(Message::Text(payload.into())).await.is_err() { break; } } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c020fa00cb..53b5a26b02 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -163,3 +163,79 @@ async fn interrupt_tool_records_history_entries() { "expected at least one tenth of a second of elapsed time, got {secs}" ); } + +/// After an interrupt we persist a model-visible `` marker in the conversation +/// history. This test asserts that the marker is included in the next `/responses` request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_persists_turn_aborted_marker_in_next_request() { + let command = "sleep 60"; + let call_id = "call-turn-aborted-marker"; + + let args = json!({ + "command": command, + "timeout_ms": 60_000 + }) + .to_string(); + let first_body = sse(vec![ + ev_response_created("resp-marker"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-marker"), + ]); + let follow_up_body = sse(vec![ + ev_response_created("resp-followup"), + ev_completed("resp-followup"), + ]); + + let server = start_mock_server().await; + let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; + + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); + let codex = Arc::clone(&fixture.codex); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "start interrupt marker".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await; + + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; + codex.submit(Op::Interrupt).await.unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "follow up".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two calls to the responses API"); + + let follow_up_request = &requests[1]; + let user_texts = follow_up_request.message_input_texts("user"); + assert!( + user_texts + .iter() + .any(|text| text.contains("")), + "expected marker in follow-up request" + ); +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 9108f263f9..57142b07f2 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,5 +1,6 @@ use assert_cmd::Command as AssertCommand; use codex_core::RolloutRecorder; +use codex_core::auth::CODEX_API_KEY_ENV_VAR; use codex_core::protocol::GitInfo; use codex_utils_cargo_bin::find_resource; use core_test_support::fs_wait; @@ -108,11 +109,11 @@ async fn chat_mode_stream_cli() { ); } -/// Verify that passing `-c experimental_instructions_file=...` to the CLI +/// Verify that passing `-c model_instructions_file=...` to the CLI /// overrides the built-in base instructions by inspecting the request body /// received by a mock OpenAI Responses endpoint. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exec_cli_applies_experimental_instructions_file() { +async fn exec_cli_applies_model_instructions_file() { skip_if_no_network!(); // Start mock server which will capture the request and return a minimal @@ -127,7 +128,7 @@ async fn exec_cli_applies_experimental_instructions_file() { // Create a temporary instructions file with a unique marker we can assert // appears in the outbound request payload. let custom = TempDir::new().unwrap(); - let marker = "cli-experimental-instructions-marker"; + let marker = "cli-model-instructions-file-marker"; let custom_path = custom.path().join("instr.md"); std::fs::write(&custom_path, marker).unwrap(); let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); @@ -150,9 +151,7 @@ async fn exec_cli_applies_experimental_instructions_file() { .arg("-c") .arg("model_provider=\"mock\"") .arg("-c") - .arg(format!( - "experimental_instructions_file=\"{custom_path_str}\"" - )) + .arg(format!("model_instructions_file=\"{custom_path_str}\"")) .arg("-C") .arg(&repo_root) .arg("hello?\n"); @@ -239,7 +238,7 @@ async fn integration_creates_and_checks_session_file() -> anyhow::Result<()> { .arg(&repo_root) .arg(&prompt); cmd.env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + .env(CODEX_API_KEY_ENV_VAR, "dummy") .env("CODEX_RS_SSE_FIXTURE", &fixture) // Required for CLI arg parsing even though fixture short-circuits network usage. .env("OPENAI_BASE_URL", "http://unused.local"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cfae930d95..1b43f7932f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -826,7 +826,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { .get("reasoning") .and_then(|t| t.get("effort")) .and_then(|v| v.as_str()), - None + Some("medium") ); Ok(()) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index e054908aec..b7ce49abbe 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -278,6 +278,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -348,6 +349,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -409,6 +411,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -511,6 +514,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, @@ -634,6 +638,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() { "tool_choice": "auto", "parallel_tool_calls": false, "reasoning": { + "effort": "medium", "summary": "auto" }, "store": false, diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index bab715ebd8..d401a64cd4 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -1,15 +1,22 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config_loader::ConfigLayerEntry; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigRequirements; +use codex_core::config_loader::ConfigRequirementsToml; use codex_core::features::Feature; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::EventMsg; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_absolute_path; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; +use toml::Value as TomlValue; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()> { @@ -48,3 +55,58 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + let mut table = toml::map::Map::new(); + table.insert( + "experimental_instructions_file".to_string(), + TomlValue::String("legacy.md".to_string()), + ); + let config_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_absolute_path("/tmp/config.toml"), + }, + TomlValue::Table(table), + ); + let config_layer_stack = ConfigLayerStack::new( + vec![config_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("build config layer stack"); + config.config_layer_stack = config_layer_stack; + }); + + let TestCodex { codex, .. } = builder.build(&server).await?; + + let notice = wait_for_event_match(&codex, |event| match event { + EventMsg::DeprecationNotice(ev) + if ev.summary.contains("experimental_instructions_file") => + { + Some(ev.clone()) + } + _ => None, + }) + .await; + + let DeprecationNoticeEvent { summary, details } = notice; + assert_eq!( + summary, + "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." + .to_string(), + ); + assert_eq!( + details.as_deref(), + Some( + "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." + ), + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 20739ab0db..60d0dbc75f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -40,10 +40,10 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { let first_response = sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]); mount_sse_once(&server, first_response).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; let expected_input = UserInput::Text { text: "please inspect sample.txt".into(), text_elements: text_elements.clone(), diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 5f21e94537..1791d28b01 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -4,11 +4,13 @@ use codex_core::ThreadManager; use codex_core::built_in_model_providers; use codex_core::models_manager::manager::RefreshStrategy; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use core_test_support::load_default_config_for_test; use indoc::indoc; use pretty_assertions::assert_eq; +use std::collections::HashMap; use tempfile::tempdir; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -50,35 +52,21 @@ async fn list_models_returns_chatgpt_models() -> Result<()> { fn expected_models_for_api_key() -> Vec { vec![ gpt_52_codex(), - gpt_5_1_codex_max(), - gpt_5_1_codex_mini(), gpt_5_2(), + gpt_5_1_codex_max(), + gpt_5_1_codex(), + gpt_5_1_codex_mini(), + gpt_5_1(), + gpt_5_codex(), + gpt_5(), + gpt_5_codex_mini(), bengalfox(), boomslang(), - gpt_5_codex(), - gpt_5_codex_mini(), - gpt_5_1_codex(), - gpt_5(), - gpt_5_1(), ] } fn expected_models_for_chatgpt() -> Vec { - let mut gpt_5_1_codex_max = gpt_5_1_codex_max(); - gpt_5_1_codex_max.is_default = false; - vec![ - gpt_52_codex(), - gpt_5_1_codex_max, - gpt_5_1_codex_mini(), - gpt_5_2(), - bengalfox(), - boomslang(), - gpt_5_codex(), - gpt_5_codex_mini(), - gpt_5_1_codex(), - gpt_5(), - gpt_5_1(), - ] + expected_models_for_api_key() } fn gpt_52_codex() -> ModelPreset { @@ -139,7 +127,17 @@ fn gpt_5_1_codex_max() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1-codex-max", + HashMap::from([ + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::XHigh, ReasoningEffort::XHigh), + ]), + )), show_in_picker: true, supported_in_api: true, } @@ -163,7 +161,17 @@ fn gpt_5_1_codex_mini() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1-codex-mini", + HashMap::from([ + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::Minimal, ReasoningEffort::Medium), + (ReasoningEffort::None, ReasoningEffort::Medium), + (ReasoningEffort::Low, ReasoningEffort::Medium), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + ]), + )), show_in_picker: true, supported_in_api: true, } @@ -193,11 +201,21 @@ fn gpt_5_2() -> ModelPreset { ), effort( ReasoningEffort::XHigh, - "Extra high reasoning depth for complex problems", + "Extra high reasoning for complex problems", ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.2", + HashMap::from([ + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::XHigh, ReasoningEffort::XHigh), + ]), + )), show_in_picker: true, supported_in_api: true, } @@ -289,7 +307,17 @@ fn gpt_5_codex() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5-codex", + HashMap::from([ + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Low, ReasoningEffort::Low), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -313,7 +341,17 @@ fn gpt_5_codex_mini() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5-codex-mini", + HashMap::from([ + (ReasoningEffort::None, ReasoningEffort::Medium), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Low, ReasoningEffort::Medium), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::Minimal, ReasoningEffort::Medium), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -341,7 +379,17 @@ fn gpt_5_1_codex() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1-codex", + HashMap::from([ + (ReasoningEffort::Minimal, ReasoningEffort::Low), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::XHigh, ReasoningEffort::High), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -373,7 +421,17 @@ fn gpt_5() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5", + HashMap::from([ + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::Minimal, ReasoningEffort::Minimal), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::None, ReasoningEffort::Minimal), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + ]), + )), show_in_picker: false, supported_in_api: true, } @@ -401,27 +459,37 @@ fn gpt_5_1() -> ModelPreset { ), ], is_default: false, - upgrade: Some(gpt52_codex_upgrade()), + upgrade: Some(gpt52_codex_upgrade( + "gpt-5.1", + HashMap::from([ + (ReasoningEffort::None, ReasoningEffort::Low), + (ReasoningEffort::Medium, ReasoningEffort::Medium), + (ReasoningEffort::High, ReasoningEffort::High), + (ReasoningEffort::XHigh, ReasoningEffort::High), + (ReasoningEffort::Low, ReasoningEffort::Low), + (ReasoningEffort::Minimal, ReasoningEffort::Low), + ]), + )), show_in_picker: false, supported_in_api: true, } } -fn gpt52_codex_upgrade() -> codex_protocol::openai_models::ModelUpgrade { - codex_protocol::openai_models::ModelUpgrade { +fn gpt52_codex_upgrade( + migration_config_key: &str, + reasoning_effort_mapping: HashMap, +) -> ModelUpgrade { + ModelUpgrade { id: "gpt-5.2-codex".to_string(), - reasoning_effort_mapping: None, - migration_config_key: "gpt-5.2-codex".to_string(), - model_link: Some("https://openai.com/index/introducing-gpt-5-2-codex".to_string()), - upgrade_copy: Some( - "Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work." - .to_string(), - ), + reasoning_effort_mapping: Some(reasoning_effort_mapping), + migration_config_key: migration_config_key.to_string(), + model_link: None, + upgrade_copy: None, migration_markdown: Some( indoc! {r#" **Codex just got an upgrade. Introducing {model_to}.** - Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex + Codex is now powered by {model_to}, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work. Learn more about {model_to} at https://openai.com/index/introducing-gpt-5-2-codex You can continue using {model_from} if you prefer. "#} diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs new file mode 100644 index 0000000000..9c0f6e3f40 --- /dev/null +++ b/codex-rs/core/tests/suite/personality.rs @@ -0,0 +1,23 @@ +use codex_core::config::types::Personality; +use codex_core::models_manager::manager::ModelsManager; +use core_test_support::load_default_config_for_test; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!( + "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" +); +const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md"); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_personality_updates_base_instructions() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.model_personality = Some(Personality::Friendly); + + let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); + let expected = + BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY); + + assert_eq!(model_info.base_instructions, expected); +} diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index e7123c4305..3b45989d8a 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -1,4 +1,5 @@ #![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used)] // unified exec is not supported on Windows OS use std::sync::Arc; @@ -388,9 +389,10 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let base_model_info = models_manager.get_model_info("gpt-5.1", &config).await; let body = response_mock.single_request().body_json(); let instructions = body["instructions"].as_str().unwrap(); - assert_eq!(instructions, remote_base); + assert_eq!(instructions, base_model_info.base_instructions); Ok(()) } @@ -433,8 +435,18 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { .find(|model| model.model == "remote-alpha") .expect("remote model should be listed"); let mut expected_remote: ModelPreset = remote_model.into(); - expected_remote.is_default = true; + expected_remote.is_default = remote.is_default; assert_eq!(*remote, expected_remote); + let default_model = available + .iter() + .find(|model| model.show_in_picker) + .expect("default model should be set"); + assert!(default_model.is_default); + assert_eq!( + available.iter().filter(|model| model.is_default).count(), + 1, + "expected a single default model" + ); assert!( available .iter() @@ -450,6 +462,148 @@ async fn remote_models_preserve_builtin_presets() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_adds_new_high_priority_first() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let remote_model = test_remote_model("remote-top", ModelVisibility::List, -10_000); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + assert_eq!( + available.first().map(|model| model.model.as_str()), + Some("remote-top") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_replaces_overlapping_model() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let slug = bundled_model_slug(); + let mut remote_model = test_remote_model(&slug, ModelVisibility::List, 0); + remote_model.display_name = "Overridden".to_string(); + remote_model.description = Some("Overridden description".to_string()); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model.clone()], + }, + ) + .await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let overridden = available + .iter() + .find(|model| model.model == slug) + .expect("overlapping model should be listed"); + assert_eq!(overridden.display_name, remote_model.display_name); + assert_eq!( + overridden.description, + remote_model + .description + .expect("remote model should include description") + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_merge_preserves_bundled_models_on_empty_response() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::start().await; + let models_mock = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + + let codex_home = TempDir::new()?; + let mut config = load_default_config_for_test(&codex_home).await; + config.features.enable(Feature::RemoteModels); + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let manager = ModelsManager::with_provider( + codex_home.path().to_path_buf(), + codex_core::auth::AuthManager::from_auth_for_testing(auth), + provider, + ); + + let available = manager + .list_models(&config, RefreshStrategy::OnlineIfUncached) + .await; + let bundled_slug = bundled_model_slug(); + assert!( + available.iter().any(|model| model.model == bundled_slug), + "bundled models should remain available after empty remote response" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_models_request_times_out_after_5s() -> Result<()> { skip_if_no_network!(Ok(())); @@ -587,6 +741,17 @@ async fn wait_for_model_available( } } +fn bundled_model_slug() -> String { + let response: ModelsResponse = serde_json::from_str(include_str!("../../models.json")) + .expect("bundled models.json should deserialize"); + response + .models + .first() + .expect("bundled models.json should include at least one model") + .slug + .clone() +} + fn test_remote_model(slug: &str, visibility: ModelVisibility, priority: i32) -> ModelInfo { test_remote_model_with_policy( slug, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 3c4405eeb3..027d7dab77 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -7,7 +7,9 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Settings; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; @@ -45,6 +47,27 @@ fn call_output(req: &ResponsesRequest, call_id: &str) -> String { } } +fn call_output_content_and_success( + req: &ResponsesRequest, + call_id: &str, +) -> (String, Option) { + let raw = req.function_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in function_call_output" + ); + let (content_opt, success) = match req.function_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("function_call_output present"), + }; + let content = match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + }; + (content, success) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -109,7 +132,11 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, - collaboration_mode: None, + collaboration_mode: Some(CollaborationMode::Plan(Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + })), }) .await?; @@ -125,8 +152,7 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> answers.insert( "confirm_path".to_string(), RequestUserInputAnswer { - selected: vec!["yes".to_string()], - other: None, + answers: vec!["yes".to_string()], }, ); let response = RequestUserInputResponse { answers }; @@ -146,10 +172,119 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> output_json, json!({ "answers": { - "confirm_path": { "selected": ["yes"], "other": Value::Null } + "confirm_path": { "answers": ["yes"] } } }) ); Ok(()) } + +async fn assert_request_user_input_rejected(mode_name: &str, build_mode: F) -> anyhow::Result<()> +where + F: FnOnce(String) -> CollaborationMode, +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let builder = test_codex(); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder + .with_config(|config| { + config.features.enable(Feature::CollaborationModes); + }) + .build(&server) + .await?; + + let mode_slug = mode_name.to_lowercase(); + let call_id = format!("user-input-{mode_slug}-call"); + let request_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(&call_id, "request_user_input", &request_args), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "thanks"), + ev_completed("resp-2"), + ]); + let second_mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + let collaboration_mode = build_mode(session_model.clone()); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "please confirm".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: Some(collaboration_mode), + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let (output, success) = call_output_content_and_success(&req, &call_id); + assert_eq!(success, None); + assert_eq!( + output, + format!("request_user_input is unavailable in {mode_name} mode") + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Execute", |model| { + CollaborationMode::Execute(Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }) + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> { + assert_request_user_input_rejected("Custom", |model| { + CollaborationMode::Custom(Settings { + model, + reasoning_effort: None, + developer_instructions: None, + }) + }) + .await +} diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index 8912870e5e..d0718a334d 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -35,10 +35,10 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { ]); mount_sse_once(&server, initial_sse).await; - let text_elements = vec![TextElement { - byte_range: ByteRange { start: 0, end: 6 }, - placeholder: Some("".into()), - }]; + let text_elements = vec![TextElement::new( + ByteRange { start: 0, end: 6 }, + Some("".into()), + )]; codex .submit(Op::UserInput { @@ -131,3 +131,78 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_switches_models_preserves_base_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2".to_string()); + }); + let initial = builder.build(&server).await?; + let codex = Arc::clone(&initial.codex); + let home = initial.home.clone(); + let rollout_path = initial.session_configured.rollout_path.clone(); + + let initial_sse = sse(vec![ + ev_response_created("resp-initial"), + ev_assistant_message("msg-1", "Completed first turn"), + ev_completed("resp-initial"), + ]); + let initial_mock = mount_sse_once(&server, initial_sse).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Record initial instructions".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let initial_body = initial_mock.single_request().body_json(); + let initial_instructions = initial_body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let resumed_sse = sse(vec![ + ev_response_created("resp-resume"), + ev_assistant_message("msg-2", "Resumed turn"), + ev_completed("resp-resume"), + ]); + let resumed_mock = mount_sse_once(&server, resumed_sse).await; + + let mut resume_builder = test_codex().with_config(|config| { + config.model = Some("gpt-5.2-codex".to_string()); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + resumed + .codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Resume with different model".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&resumed.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let resumed_body = resumed_mock.single_request().body_json(); + let resumed_instructions = resumed_body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + assert_eq!(resumed_instructions, initial_instructions); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index df0534bf40..162e0e26ed 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -26,9 +26,9 @@ fn resume_history( approval_policy: config.approval_policy.value(), sandbox_policy: config.sandbox_policy.get().clone(), model: previous_model.to_string(), + collaboration_mode: None, effort: config.model_reasoning_effort, summary: config.model_reasoning_summary, - base_instructions: None, user_instructions: None, developer_instructions: None, final_output_json_schema: None, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 443a41abab..2177ab9bdf 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -20,9 +20,11 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::Path; use std::path::PathBuf; use tokio::fs; use tokio::time::Duration; +use tokio::time::Instant; use tokio::time::sleep; #[derive(Debug)] @@ -34,6 +36,24 @@ struct SnapshotRun { codex_home: PathBuf, } +async fn wait_for_snapshot(codex_home: &Path) -> Result { + let snapshot_dir = codex_home.join("shell_snapshots"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(mut entries) = fs::read_dir(&snapshot_dir).await + && let Some(entry) = entries.next_entry().await? + { + return Ok(entry.path()); + } + + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for shell snapshot"); + } + + sleep(Duration::from_millis(25)).await; + } +} + #[allow(clippy::expect_used)] async fn run_snapshot_command(command: &str) -> Result { let builder = test_codex().with_config(|config| { @@ -89,12 +109,7 @@ async fn run_snapshot_command(command: &str) -> Result { _ => None, }) .await; - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; let end = wait_for_event_match(&codex, |ev| match ev { @@ -167,12 +182,7 @@ async fn run_shell_command_snapshot(command: &str) -> Result { _ => None, }) .await; - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; let end = wait_for_event_match(&codex, |ev| match ev { @@ -305,12 +315,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { assert_eq!(fs::read_to_string(&target).await?, "hello from snapshot\n"); - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; let snapshot_content = fs::read_to_string(&snapshot_path).await?; assert_posix_snapshot_sections(&snapshot_content); @@ -328,12 +333,7 @@ async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> { let codex_home = home.path().to_path_buf(); let codex = harness.test().codex.clone(); - let mut entries = fs::read_dir(codex_home.join("shell_snapshots")).await?; - let snapshot_path = entries - .next_entry() - .await? - .map(|entry| entry.path()) - .expect("shell snapshot created"); + let snapshot_path = wait_for_snapshot(&codex_home).await?; assert!(snapshot_path.exists()); codex.submit(Op::Shutdown {}).await?; diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 05c02d4228..42a2a28ae7 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -69,9 +69,9 @@ async fn build_codex_with_test_tool(server: &wiremock::MockServer) -> anyhow::Re } fn assert_parallel_duration(actual: Duration) { - // Allow headroom for runtime overhead while still differentiating from serial execution. + // Allow headroom for slow CI scheduling; barrier synchronization already enforces overlap. assert!( - actual < Duration::from_millis(750), + actual < Duration::from_millis(1_200), "expected parallel execution to finish quickly, got {actual:?}" ); } @@ -308,7 +308,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an let args = json!({ "command": command, "login": false, - "timeout_ms": 1_000, + "timeout_ms": 5_000, }); let first_chunk = sse(vec![ @@ -371,7 +371,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an let _ = first_gate_tx.send(()); let _ = follow_up_gate_tx.send(()); - let timestamps = tokio::time::timeout(Duration::from_secs(1), async { + let timestamps = tokio::time::timeout(Duration::from_secs(5), async { loop { let contents = fs::read_to_string(output_path)?; let timestamps = contents diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 91e9f35f38..74cb1c55c8 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -1,6 +1,7 @@ use anyhow::Context; use codex_core::NewThread; use codex_core::ThreadManager; +use codex_core::features::Feature; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; @@ -129,7 +130,10 @@ async fn user_shell_cmd_can_be_interrupted() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyhow::Result<()> { let server = responses::start_mock_server().await; - let mut builder = core_test_support::test_codex::test_codex(); + // Disable it to ease command matching. + let mut builder = core_test_support::test_codex::test_codex().with_config(move |config| { + config.features.disable(Feature::ShellSnapshot); + }); let test = builder.build(&server).await?; #[cfg(windows)] diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 377dc02a8d..673a2a8fbc 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -187,7 +187,7 @@ impl AppServerClient { thread_id: thread_id.to_string(), input: vec![UserInput::Text { text, - // Plain text conversion has no UI element ranges. + // Debug client sends plain text with no UI markup spans. text_elements: Vec::new(), }], ..Default::default() diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a76510ed95..a7629a107f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -360,6 +360,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .collect(); items.push(UserInput::Text { text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. text_elements: Vec::new(), }); let output_schema = load_output_schema(output_schema_path.clone()); @@ -379,6 +380,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .collect(); items.push(UserInput::Text { text: prompt_text.clone(), + // CLI input doesn't track UI element ranges, so none are available here. text_elements: Vec::new(), }); let output_schema = load_output_schema(output_schema_path); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b268b8f1b4..b400b4cc79 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -122,6 +122,7 @@ pub async fn run_codex_tool_session( op: Op::UserInput { items: vec![UserInput::Text { text: initial_prompt.clone(), + // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], final_output_json_schema: None, @@ -167,7 +168,7 @@ pub async fn run_codex_tool_session_reply( .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt, - // Plain text conversion has no UI element ranges. + // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], final_output_json_schema: None, diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index eb19ec7df7..41c1df64b7 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -43,7 +43,7 @@ opentelemetry-otlp = { workspace = true, features = [ opentelemetry-semantic-conventions = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["logs", "metrics", "rt-tokio", "testing", "trace"] } http = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index fa7cc93bb7..32540d1071 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -170,3 +170,11 @@ impl OtelManager { Ok(()) } } + +/// Start a metrics timer using the globally installed metrics client. +pub fn start_global_timer(name: &str, tags: &[(&str, &str)]) -> MetricsResult { + let Some(metrics) = crate::metrics::global() else { + return Err(MetricsError::ExporterDisabled); + }; + metrics.start_timer(name, tags) +} diff --git a/codex-rs/otel/src/metrics/timer.rs b/codex-rs/otel/src/metrics/timer.rs index 6c85f413b3..86712f6a0e 100644 --- a/codex-rs/otel/src/metrics/timer.rs +++ b/codex-rs/otel/src/metrics/timer.rs @@ -12,7 +12,7 @@ pub struct Timer { impl Drop for Timer { fn drop(&mut self) { - if let Err(e) = self.record() { + if let Err(e) = self.record(&[]) { tracing::error!("metrics client error: {}", e); } } @@ -31,12 +31,10 @@ impl Timer { } } - pub fn record(&self) -> Result<()> { - let tags = self - .tags - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect::>(); + pub fn record(&self, additional_tags: &[(&str, &str)]) -> Result<()> { + let mut tags = Vec::with_capacity(self.tags.len() + additional_tags.len()); + tags.extend(additional_tags); + tags.extend(self.tags.iter().map(|(k, v)| (k.as_str(), v.as_str()))); self.client .record_duration(&self.name, self.start_time.elapsed(), &tags) } diff --git a/codex-rs/protocol/BUILD.bazel b/codex-rs/protocol/BUILD.bazel index 2c00d076bc..e47bc8c16b 100644 --- a/codex-rs/protocol/BUILD.bazel +++ b/codex-rs/protocol/BUILD.bazel @@ -3,5 +3,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "protocol", crate_name = "codex_protocol", - compile_data = glob(["src/prompts/permissions/**/*.md"]), + compile_data = glob(["src/prompts/**/*.md"]), ) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index cb04d427b9..9276a759cc 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -97,13 +97,14 @@ impl UserMessageItem { // Text element ranges are relative to each text chunk; offset them so they align // with the concatenated message returned by `message()`. for elem in text_elements { - out.push(TextElement { - byte_range: ByteRange { - start: offset + elem.byte_range.start, - end: offset + elem.byte_range.end, - }, - placeholder: elem.placeholder.clone(), - }); + let byte_range = ByteRange { + start: offset + elem.byte_range.start, + end: offset + elem.byte_range.end, + }; + out.push(TextElement::new( + byte_range, + elem.placeholder(text).map(str::to_string), + )); } offset += text.len(); } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 8f8c9b3077..1b22fb925f 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -167,6 +167,23 @@ pub enum ResponseItem { Other, } +pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md"); + +/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename = "base_instructions", rename_all = "snake_case")] +pub struct BaseInstructions { + pub text: String, +} + +impl Default for BaseInstructions { + fn default() -> Self { + Self { + text: BASE_INSTRUCTIONS_DEFAULT.to_string(), + } + } +} + /// Developer-provided guidance that is injected into a turn as a developer role /// message. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/prompts/base_instructions/default.md b/codex-rs/protocol/src/prompts/base_instructions/default.md new file mode 100644 index 0000000000..4886c7ef44 --- /dev/null +++ b/codex-rs/protocol/src/prompts/base_instructions/default.md @@ -0,0 +1,275 @@ +You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful. + +Your capabilities: + +- Receive user prompts and other context provided by the harness, such as files in the workspace. +- Communicate with the user by streaming thinking & responses, and by making & updating plans. +- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section. + +Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI). + +# How you work + +## Personality + +Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work. + +# AGENTS.md spec +- Repos often contain AGENTS.md files. These files can appear anywhere within the repository. +- These files are a way for humans to give you (the agent) instructions or tips for working within the container. +- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code. +- Instructions in AGENTS.md files: + - The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it. + - For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file. + - Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise. + - More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions. + - Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions. +- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable. + +## Responsiveness + +### Preamble messages + +Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples: + +- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each. +- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates). +- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions. +- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging. +- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action. + +**Examples:** + +- “I’ve explored the repo; now checking the API route definitions.” +- “Next, I’ll patch the config and update the related tests.” +- “I’m about to scaffold the CLI commands and helper functions.” +- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.” +- “Config’s looking tidy. Next up is patching helpers to keep things in sync.” +- “Finished poking at the DB gateway. I will now chase down error handling.” +- “Alright, build pipeline order is interesting. Checking how it reports failures.” +- “Spotted a clever caching util; now hunting where it gets used.” + +## Planning + +You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go. + +Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately. + +Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step. + +Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so. + +Use a plan when: + +- The task is non-trivial and will require multiple actions over a long time horizon. +- There are logical phases or dependencies where sequencing matters. +- The work has ambiguity that benefits from outlining high-level goals. +- You want intermediate checkpoints for feedback and validation. +- When the user asked you to do more than one thing in a single prompt +- The user has asked you to use the plan tool (aka "TODOs") +- You generate additional steps while working, and plan to do them before yielding to the user + +### Examples + +**High-quality plans** + +Example 1: + +1. Add CLI entry with file args +2. Parse Markdown via CommonMark library +3. Apply semantic HTML template +4. Handle code blocks, images, links +5. Add error handling for invalid files + +Example 2: + +1. Define CSS variables for colors +2. Add toggle with localStorage state +3. Refactor components to use variables +4. Verify all views for readability +5. Add smooth theme-change transition + +Example 3: + +1. Set up Node.js + WebSocket server +2. Add join/leave broadcast events +3. Implement messaging with timestamps +4. Add usernames + mention highlighting +5. Persist messages in lightweight DB +6. Add typing indicators + unread count + +**Low-quality plans** + +Example 1: + +1. Create CLI tool +2. Add Markdown parser +3. Convert to HTML + +Example 2: + +1. Add dark mode toggle +2. Save preference +3. Make styles look good + +Example 3: + +1. Create single-file HTML game +2. Run quick sanity check +3. Summarize usage instructions + +If you need to write a plan, only write high quality plans, not low quality ones. + +## Task execution + +You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer. + +You MUST adhere to the following criteria when solving queries: + +- Working on the repo(s) in the current environment is allowed, even if they are proprietary. +- Analyzing code for vulnerabilities is allowed. +- Showing user code and tool call details is allowed. +- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]} + +If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines: + +- Fix the problem at the root cause rather than applying surface-level patches, when possible. +- Avoid unneeded complexity in your solution. +- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.) +- Update documentation as necessary. +- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task. +- Use `git log` and `git blame` to search the history of the codebase if additional context is required. +- NEVER add copyright or license headers unless specifically requested. +- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc. +- Do not `git commit` your changes or create new git branches unless explicitly requested. +- Do not add inline comments within code unless explicitly requested. +- Do not use one-letter variable names unless explicitly requested. +- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor. + +## Validating your work + +If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete. + +When testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests. + +Similarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one. + +For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.) + +Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance: + +- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task. +- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first. +- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task. + +## Ambition vs. precision + +For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation. + +If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature. + +You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified. + +## Sharing progress updates + +For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next. + +Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why. + +The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along. + +## Presenting your work and final message + +Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges. + +You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation. + +The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path. + +If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly. + +Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding. + +### Final answer structure and style guidelines + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +**Section Headers** + +- Use only when they improve clarity — they are not mandatory for every answer. +- Choose descriptive names that fit the content +- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**` +- Leave no blank line before the first bullet under a header. +- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer. + +**Bullets** + +- Use `-` followed by a space for every bullet. +- Merge related points when possible; avoid a bullet for every trivial detail. +- Keep bullets to one line unless breaking for clarity is unavoidable. +- Group into short lists (4–6 bullets) ordered by importance. +- Use consistent keyword phrasing and formatting across sections. + +**Monospace** + +- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``). +- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command. +- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``). + +**File References** +When referencing files in your response, make sure to include the relevant start line and always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5 + +**Structure** + +- Place related bullets together; don’t mix unrelated concepts in the same section. +- Order sections from general → specific → supporting info. +- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it. +- Match structure to complexity: + - Multi-part or detailed results → use clear headers and grouped bullets. + - Simple results → minimal headers, possibly just a short list or paragraph. + +**Tone** + +- Keep the voice collaborative and natural, like a coding partner handing off work. +- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition +- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”). +- Keep descriptions self-contained; don’t refer to “above” or “below”. +- Use parallel structure in lists for consistency. + +**Don’t** + +- Don’t use literal words “bold” or “monospace” in the content. +- Don’t nest bullets or create deep hierarchies. +- Don’t output ANSI escape codes directly — the CLI renderer applies them. +- Don’t cram unrelated keywords into a single bullet; split for clarity. +- Don’t let keyword lists run long — wrap or reformat for scanability. + +Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable. + +For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting. + +# Tool Guidelines + +## Shell commands + +When using the shell, you must adhere to the following guidelines: + +- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) +- Do not use python scripts to attempt to output larger chunks of a file. + +## `update_plan` + +A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task. + +To create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`). + +When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call. + +If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`. diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index eab09adede..043a28e308 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -18,6 +18,7 @@ use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; use crate::message_history::HistoryEntry; +use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; @@ -1422,6 +1423,14 @@ impl InitialHistory { } } + pub fn session_cwd(&self) -> Option { + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => session_cwd_from_items(&resumed.history), + InitialHistory::Forked(items) => session_cwd_from_items(items), + } + } + pub fn get_rollout_items(&self) -> Vec { match self { InitialHistory::New => Vec::new(), @@ -1454,6 +1463,30 @@ impl InitialHistory { ), } } + + pub fn get_base_instructions(&self) -> Option { + // TODO: SessionMeta should (in theory) always be first in the history, so we can probably only check the first item? + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), + _ => None, + }) + } + InitialHistory::Forked(items) => items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => meta_line.meta.base_instructions.clone(), + _ => None, + }), + } + } +} + +fn session_cwd_from_items(items: &[RolloutItem]) -> Option { + items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.cwd.clone()), + _ => None, + }) } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] @@ -1502,6 +1535,11 @@ impl fmt::Display for SubAgentSource { } } +/// SessionMeta contains session-level data that doesn't correspond to a specific turn. +/// +/// NOTE: There used to be an `instructions` field here, which stored user_instructions, but we +/// now save that on TurnContext. base_instructions stores the base instructions for the session, +/// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { pub id: ThreadId, @@ -1514,6 +1552,10 @@ pub struct SessionMeta { #[serde(default)] pub source: SessionSource, pub model_provider: Option, + /// base_instructions for the session. This *should* always be present when creating a new session, + /// but may be missing for older sessions. If not present, fall back to rendering the base_instructions + /// from ModelsManager. + pub base_instructions: Option, } impl Default for SessionMeta { @@ -1527,6 +1569,7 @@ impl Default for SessionMeta { cli_version: String::new(), source: SessionSource::default(), model_provider: None, + base_instructions: None, } } } @@ -1574,12 +1617,12 @@ pub struct TurnContextItem { pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, pub summary: ReasoningSummaryConfig, #[serde(skip_serializing_if = "Option::is_none")] - pub base_instructions: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub user_instructions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub developer_instructions: Option, diff --git a/codex-rs/protocol/src/request_user_input.rs b/codex-rs/protocol/src/request_user_input.rs index 44ad3b6302..7c0e02c592 100644 --- a/codex-rs/protocol/src/request_user_input.rs +++ b/codex-rs/protocol/src/request_user_input.rs @@ -27,8 +27,7 @@ pub struct RequestUserInputArgs { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestUserInputAnswer { - pub selected: Vec, - pub other: Option, + pub answers: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index b9ee693d85..e9ca09580e 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -36,7 +36,51 @@ pub struct TextElement { /// Byte range in the parent `text` buffer that this element occupies. pub byte_range: ByteRange, /// Optional human-readable placeholder for the element, displayed in the UI. - pub placeholder: Option, + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + /// Returns a copy of this element with a remapped byte range. + /// + /// The placeholder is preserved as-is; callers must ensure the new range + /// still refers to the same logical element (and same placeholder) + /// within the new text. + pub fn map_range(&self, map: F) -> Self + where + F: FnOnce(ByteRange) -> ByteRange, + { + Self { + byte_range: map(self.byte_range), + placeholder: self.placeholder.clone(), + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + /// Returns the stored placeholder without falling back to the text buffer. + /// + /// This must only be used inside `From` implementations on equivalent + /// protocol types where the source text is unavailable. Prefer `placeholder(text)` + /// everywhere else. + #[doc(hidden)] + pub fn _placeholder_for_conversion_only(&self) -> Option<&str> { + self.placeholder.as_deref() + } + + pub fn placeholder<'a>(&'a self, text: &'a str) -> Option<&'a str> { + self.placeholder + .as_deref() + .or_else(|| text.get(self.byte_range.start..self.byte_range.end)) + } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index e0ea600033..a2226dba6a 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -21,7 +21,7 @@ clap = { workspace = true, features = ["derive"] } codex-process-hardening = { workspace = true } ctor = { workspace = true } libc = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls-webpki-roots", "rustls-tls-native-roots"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 8aa7512faf..2e874c6089 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -23,7 +23,8 @@ oauth2 = "5" reqwest = { version = "0.12", default-features = false, features = [ "json", "stream", - "rustls-tls", + "rustls-tls-webpki-roots", + "rustls-tls-native-roots", ] } rmcp = { workspace = true, default-features = false, features = [ "auth", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 74b95be514..b5ae3caa86 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ codex-core = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } +codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } color-eyre = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 057eae4c11..cd4bfe55c1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -29,6 +29,7 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEdit; @@ -47,6 +48,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -70,6 +72,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; +use std::time::Instant; use tokio::select; use tokio::sync::broadcast; use tokio::sync::mpsc::unbounded_channel; @@ -84,6 +87,17 @@ pub struct AppExitInfo { pub exit_reason: ExitReason, } +impl AppExitInfo { + pub fn fatal(message: impl Into) -> Self { + Self { + token_usage: TokenUsage::default(), + thread_id: None, + update_action: None, + exit_reason: ExitReason::Fatal(message.into()), + } + } +} + #[derive(Debug)] pub(crate) enum AppRunControl { Continue, @@ -272,7 +286,6 @@ async fn handle_model_migration_prompt_if_needed( from_model: model.to_string(), to_model: target_model.clone(), }); - config.model = Some(target_model.clone()); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -285,8 +298,8 @@ async fn handle_model_migration_prompt_if_needed( config.model_reasoning_effort }; + config.model = Some(target_model.clone()); config.model_reasoning_effort = mapped_effort; - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); app_event_tx.send(AppEvent::PersistModelSelection { @@ -316,12 +329,12 @@ async fn handle_model_migration_prompt_if_needed( pub(crate) struct App { pub(crate) server: Arc, + pub(crate) otel_manager: OtelManager, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, - pub(crate) current_model: String, pub(crate) active_profile: Option, pub(crate) file_search: FileSearchManager, @@ -353,8 +366,7 @@ pub(crate) struct App { /// stopping a thread (e.g., before starting a new one). suppress_shutdown_complete: bool, - // One-shot suppression of the next world-writable scan after user confirmation. - skip_world_writable_scan_once: bool, + windows_sandbox: WindowsSandboxState, // TODO(jif) drop once new UX is here. // Track external agent approvals spawned via AgentControl. @@ -364,7 +376,35 @@ pub(crate) struct App { paused_codex_events: VecDeque, } +#[derive(Default)] +struct WindowsSandboxState { + setup_started_at: Option, + // One-shot suppression of the next world-writable scan after user confirmation. + skip_world_writable_scan_once: bool, +} + impl App { + pub fn chatwidget_init_for_forked_or_resumed_thread( + &self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + ) -> crate::chatwidget::ChatWidgetInit { + crate::chatwidget::ChatWidgetInit { + config: cfg, + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // Fork/resume bootstraps here don't carry any prefilled message content. + initial_user_message: None, + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: Some(self.chat_widget.current_model().to_string()), + otel_manager: self.otel_manager.clone(), + } + } + async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { // Clear any in-flight rollback guard when switching threads. @@ -421,6 +461,24 @@ impl App { model = updated_model; } + let auth = auth_manager.auth().await; + let auth_ref = auth.as_ref(); + let model_info = thread_manager + .get_models_manager() + .get_model_info(model.as_str(), &config) + .await; + let otel_manager = OtelManager::new( + ThreadId::new(), + model.as_str(), + model_info.slug.as_str(), + auth_ref.and_then(CodexAuth::get_account_id), + auth_ref.and_then(CodexAuth::get_account_email), + auth_ref.map(|auth| auth.mode), + config.otel.log_user_prompt, + codex_core::terminal::user_agent(), + SessionSource::Cli, + ); + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { @@ -428,14 +486,19 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, - model: config.model.clone(), + model: Some(model.clone()), + otel_manager: otel_manager.clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -451,14 +514,19 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, model: config.model.clone(), + otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } @@ -474,14 +542,19 @@ impl App { config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), + initial_user_message: crate::chatwidget::create_initial_user_message( + initial_prompt.clone(), + initial_images.clone(), + // CLI prompt args are plain strings, so they don't provide element ranges. + Vec::new(), + ), enhanced_keys_supported, auth_manager: auth_manager.clone(), models_manager: thread_manager.get_models_manager(), feedback: feedback.clone(), is_first_run, model: config.model.clone(), + otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -495,11 +568,11 @@ impl App { let mut app = Self { server: thread_manager.clone(), + otel_manager: otel_manager.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), config, - current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -513,7 +586,7 @@ impl App { feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), external_approval_routes: HashMap::new(), paused_codex_events: VecDeque::new(), }; @@ -662,13 +735,9 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - let model_info = self - .server - .get_models_manager() - .get_model_info(self.current_model.as_str(), &self.config) - .await; match event { AppEvent::NewSession => { + let model = self.chat_widget.current_model().to_string(); let summary = session_summary(self.chat_widget.token_usage(), self.chat_widget.thread_id()); self.shutdown_current_thread().await; @@ -679,17 +748,17 @@ impl App { config: self.config.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), + // New sessions start without prefilled message content. + initial_user_message: None, enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), is_first_run: false, - model: Some(self.current_model.clone()), + model: Some(model), + otel_manager: self.otel_manager.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -725,25 +794,15 @@ impl App { { Ok(resumed) => { self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, resumed.thread, resumed.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -784,25 +843,15 @@ impl App { { Ok(forked) => { self.shutdown_current_thread().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + ); self.chat_widget = ChatWidget::new_from_existing( init, forked.thread, forked.session_configured, ); - self.current_model = model_info.slug.clone(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -946,6 +995,24 @@ impl App { .submit_op(Op::PatchApproval { id, decision }); } } + Op::UserInputAnswer { id, response } => { + if let Some((thread_id, original_id)) = + self.external_approval_routes.remove(&id) + { + self.forward_external_op( + thread_id, + Op::UserInputAnswer { + id: original_id, + response, + }, + ) + .await; + self.finish_external_approval(); + } else { + self.chat_widget + .submit_op(Op::UserInputAnswer { id, response }); + } + } // Standard path where this is not an external approval response. _ => self.chat_widget.submit_op(op), }, @@ -984,7 +1051,11 @@ impl App { } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); - self.current_model = model; + } + AppEvent::UpdateCollaborationMode(mode) => { + let model = mode.model().to_string(); + self.chat_widget.set_collaboration_mode(mode); + self.chat_widget.set_model(&model); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); @@ -1026,7 +1097,16 @@ impl App { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { + self.otel_manager + .counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]); self.chat_widget.clear_windows_sandbox_setup_status(); + if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { + self.otel_manager.record_duration( + "codex.windows_sandbox.elevated_setup_duration_ms", + started_at.elapsed(), + &[("result", "failure")], + ); + } self.chat_widget .open_windows_sandbox_fallback_prompt(preset, reason); } @@ -1053,6 +1133,8 @@ impl App { } self.chat_widget.show_windows_sandbox_setup_status(); + self.windows_sandbox.setup_started_at = Some(Instant::now()); + let otel_manager = self.otel_manager.clone(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( &policy, @@ -1062,11 +1144,23 @@ impl App { codex_home.as_path(), ); let event = match result { - Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }, + Ok(()) => { + otel_manager.counter( + "codex.windows_sandbox.elevated_setup_success", + 1, + &[], + ); + AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Elevated, + } + } Err(err) => { + otel_manager.counter( + "codex.windows_sandbox.elevated_setup_failure", + 1, + &[], + ); tracing::error!( error = %err, "failed to run elevated Windows sandbox setup" @@ -1089,6 +1183,13 @@ impl App { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); + if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { + self.otel_manager.record_duration( + "codex.windows_sandbox.elevated_setup_duration_ms", + started_at.elapsed(), + &[("result", "success")], + ); + } let profile = self.active_profile.as_deref(); let feature_key = Feature::WindowsSandbox.key(); let elevated_key = Feature::WindowsSandboxElevated.key(); @@ -1238,8 +1339,8 @@ impl App { #[cfg(target_os = "windows")] { // One-shot suppression if the user just confirmed continue. - if self.skip_world_writable_scan_once { - self.skip_world_writable_scan_once = false; + if self.windows_sandbox.skip_world_writable_scan_once { + self.windows_sandbox.skip_world_writable_scan_once = false; return Ok(AppRunControl::Continue); } @@ -1300,7 +1401,7 @@ impl App { } } AppEvent::SkipNextWorldWritableScan => { - self.skip_world_writable_scan_once = true; + self.windows_sandbox.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); @@ -1449,6 +1550,14 @@ impl App { /// can be routed back to the correct thread. fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) { match &mut event.msg { + EventMsg::RequestUserInput(ev) => { + let original_id = ev.turn_id.clone(); + let routing_id = format!("{thread_id}:{original_id}"); + self.external_approval_routes + .insert(routing_id.clone(), (thread_id, original_id)); + ev.turn_id = routing_id.clone(); + event.id = routing_id; + } EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { let original_id = event.id.clone(); let routing_id = format!("{thread_id}:{original_id}"); @@ -1501,7 +1610,9 @@ impl App { } }; match event.msg { - EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { + EventMsg::ExecApprovalRequest(_) + | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::RequestUserInput(_) => { app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event }); } _ => {} @@ -1534,8 +1645,10 @@ impl App { } fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); + // TODO(aibrahim): Remove this and don't use config as a state object. + // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort; + self.chat_widget.set_reasoning_effort(effort); } async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { @@ -1720,11 +1833,14 @@ mod tests { use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; + use codex_core::protocol::SessionSource; + use codex_otel::OtelManager; use codex_protocol::ThreadId; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -1737,7 +1853,6 @@ mod tests { async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1745,14 +1860,16 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let otel_manager = test_otel_manager(&config, model.as_str()); App { server, + otel_manager, app_event_tx, chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1766,7 +1883,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), external_approval_routes: HashMap::new(), paused_codex_events: VecDeque::new(), } @@ -1779,7 +1896,6 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); let server = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1787,15 +1903,17 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let otel_manager = test_otel_manager(&config, model.as_str()); ( App { server, + otel_manager, app_event_tx, chat_widget, auth_manager, config, - current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1809,7 +1927,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), external_approval_routes: HashMap::new(), paused_codex_events: VecDeque::new(), }, @@ -1818,6 +1936,21 @@ mod tests { ) } + fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + let model_info = ModelsManager::construct_model_info_offline(model, config); + OtelManager::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + None, + None, + None, + false, + "test".to_string(), + SessionSource::Cli, + ) + } + fn all_model_presets() -> Vec { codex_core::models_manager::model_presets::all_model_presets().clone() } @@ -1980,20 +2113,19 @@ mod tests { } #[tokio::test] - async fn update_reasoning_effort_updates_config() { + async fn update_reasoning_effort_updates_collaboration_mode() { let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!( - app.config.model_reasoning_effort, + app.chat_widget.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, + app.config.model_reasoning_effort, Some(ReasoningEffortConfig::High) ); } @@ -2005,6 +2137,8 @@ mod tests { let user_cell = |text: &str| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -2031,9 +2165,14 @@ mod tests { }; Arc::new(new_session_info( app.chat_widget.config_ref(), - app.current_model.as_str(), + app.chat_widget.current_model(), event, is_first, + app.chat_widget + .config_ref() + .features + .enabled(codex_core::features::Feature::CollaborationModes), + app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 80fc65ca77..9a77b6bed7 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -204,7 +204,10 @@ impl App { }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill); + // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so + // backtrack preserves image placeholders and attachments. + self.chat_widget + .set_composer_text(prefill, Vec::new(), Vec::new()); } } @@ -554,6 +557,8 @@ mod tests { let mut cells: Vec> = vec![ Arc::new(UserHistoryCell { message: "first user".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -570,6 +575,8 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -598,11 +605,15 @@ mod tests { as Arc, Arc::new(UserHistoryCell { message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, Arc::new(UserHistoryCell { message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a057150ea0..38a765ee87 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -24,6 +24,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -111,6 +112,9 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Update the current collaboration mode in the running app and widget. + UpdateCollaborationMode(CollaborationMode), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md index 32d2037c13..b5328217db 100644 --- a/codex-rs/tui/src/bottom_pane/AGENTS.md +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -6,8 +6,7 @@ When changing the paste-burst or chat-composer state machines in this folder, ke readable, top-down explanation of the current behavior. - Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). -- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence - is intentional and documented. +- Keep implementations/docstrings aligned unless a divergence is intentional and documented. Practical check: diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 499801cbb0..b3be4fed1f 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -1,5 +1,6 @@ use crate::bottom_pane::ApprovalRequest; use crate::render::renderable::Renderable; +use codex_protocol::request_user_input::RequestUserInputEvent; use crossterm::event::KeyEvent; use super::CancellationEvent; @@ -34,4 +35,13 @@ pub(crate) trait BottomPaneView: Renderable { ) -> Option { Some(request) } + + /// Try to handle request_user_input; return the original value if not + /// consumed. + fn try_consume_user_input_request( + &mut self, + request: RequestUserInputEvent, + ) -> Option { + Some(request) + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8b545559a4..1cd4c246f8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -15,6 +15,18 @@ //! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call //! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. //! +//! # Submission and Prompt Expansion +//! +//! On submit/queue paths, the composer: +//! +//! - Expands pending paste placeholders so element ranges align with the final text. +//! - Trims whitespace and rebases text elements accordingly. +//! - Expands `/prompts:` custom prompts (named or numeric args), preserving text elements. +//! - Prunes attached images so only placeholders that survive expansion are sent. +//! +//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion +//! and attachment pruning, and clears pending paste state on success. +//! //! # Non-bracketed Paste Bursts //! //! On some terminals (especially on Windows), pastes arrive as a rapid sequence of @@ -111,10 +123,13 @@ use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; @@ -128,6 +143,7 @@ use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::VecDeque; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; @@ -146,8 +162,14 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { - Submitted(String), - Queued(String), + Submitted { + text: String, + text_elements: Vec, + }, + Queued { + text: String, + text_elements: Vec, + }, Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -165,8 +187,14 @@ enum PromptSelectionMode { } enum PromptSelectionAction { - Insert { text: String, cursor: Option }, - Submit { text: String }, + Insert { + text: String, + cursor: Option, + }, + Submit { + text: String, + text_elements: Vec, + }, } pub(crate) struct ChatComposer { @@ -355,7 +383,8 @@ impl ChatComposer { let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { return false; }; - self.set_text_content(text); + // Composer history (↑/↓) stores plain text only; no UI element ranges/attachments to restore here. + self.set_text_content(text, Vec::new(), Vec::new()); true } @@ -378,6 +407,7 @@ impl ChatComposer { /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { + let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); @@ -478,7 +508,7 @@ impl ChatComposer { self.attached_images = kept_images; // Rebuild textarea so placeholders become elements again. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attached_images { *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; @@ -545,12 +575,32 @@ impl ChatComposer { } /// Replace the entire composer content with `text` and reset cursor. - pub(crate) fn set_text_content(&mut self, text: String) { + /// This clears any pending paste payloads. + pub(crate) fn set_text_content( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); - self.textarea.set_text(&text); + + self.textarea.set_text_with_elements(&text, &text_elements); + + let image_placeholders: HashSet = text_elements + .iter() + .filter_map(|elem| elem.placeholder(&text).map(str::to_string)) + .collect(); + for (idx, path) in local_image_paths.into_iter().enumerate() { + let placeholder = local_image_label_text(idx + 1); + if image_placeholders.contains(&placeholder) { + self.attached_images + .push(AttachedImage { placeholder, path }); + } + } + self.textarea.set_cursor(0); self.sync_popups(); } @@ -560,7 +610,7 @@ impl ChatComposer { return None; } let previous = self.current_text(); - self.set_text_content(String::new()); + self.set_text_content(String::new(), Vec::new(), Vec::new()); self.history.reset_navigation(); self.history.record_local_submission(&previous); Some(previous) @@ -571,6 +621,40 @@ impl ChatComposer { self.textarea.text().to_string() } + pub(crate) fn text_elements(&self) -> Vec { + self.textarea.text_elements() + } + + #[cfg(test)] + pub(crate) fn local_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + + pub(crate) fn local_images(&self) -> Vec { + self.attached_images + .iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder.clone(), + path: img.path.clone(), + }) + .collect() + } + + fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) { + if self.attached_images.is_empty() { + return; + } + let image_placeholders: HashSet<&str> = text_elements + .iter() + .filter_map(|elem| elem.placeholder(text)) + .collect(); + self.attached_images + .retain(|img| image_placeholders.contains(img.placeholder.as_str())); + } + /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { let image_number = self.attached_images.len() + 1; @@ -582,11 +666,23 @@ impl ChatComposer { .push(AttachedImage { placeholder, path }); } + #[cfg(test)] pub fn take_recent_submission_images(&mut self) -> Vec { let images = std::mem::take(&mut self.attached_images); images.into_iter().map(|img| img.path).collect() } + pub fn take_recent_submission_images_with_placeholders(&mut self) -> Vec { + let images = std::mem::take(&mut self.attached_images); + images + .into_iter() + .map(|img| LocalImageAttachment { + placeholder: img.placeholder, + path: img.path, + }) + .collect() + } + /// Flushes any due paste-burst state. /// /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: @@ -765,7 +861,7 @@ impl ChatComposer { match sel { CommandItem::Builtin(cmd) => { if cmd == SlashCommand::Skills { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } @@ -773,7 +869,8 @@ impl ChatComposer { .trim_start() .starts_with(&format!("/{}", cmd.command())); if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); + self.textarea + .set_text_clearing_elements(&format!("/{} ", cmd.command())); } if !self.textarea.text().is_empty() { cursor_target = Some(self.textarea.text().len()); @@ -785,10 +882,12 @@ impl ChatComposer { prompt, first_line, PromptSelectionMode::Completion, + &self.textarea.text_elements(), ) { PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); cursor_target = Some(target); } PromptSelectionAction::Submit { .. } => {} @@ -810,21 +909,40 @@ impl ChatComposer { // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, _rest)) = parse_slash_name(first_line) + let mut text = self.textarea.text().to_string(); + let mut text_elements = self.textarea.text_elements(); + if !self.pending_pastes.is_empty() { + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; + } + let first_line = text.lines().next().unwrap_or(""); + if let Some((name, _rest, _rest_offset)) = parse_slash_name(first_line) && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) + expand_if_numeric_with_positional_args(prompt, first_line, &text_elements) { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + self.prune_attached_images_for_submission( + &expanded.text, + &expanded.text_elements, + ); + self.pending_pastes.clear(); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text: expanded.text, + text_elements: expanded.text_elements, + }, + true, + ); } if let Some(sel) = popup.selected_item() { match sel { CommandItem::Builtin(cmd) => { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return (InputResult::Command(cmd), true); } CommandItem::UserPrompt(idx) => { @@ -833,14 +951,29 @@ impl ChatComposer { prompt, first_line, PromptSelectionMode::Submit, + &self.textarea.text_elements(), ) { - PromptSelectionAction::Submit { text } => { - self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + PromptSelectionAction::Submit { + text, + text_elements, + } => { + self.prune_attached_images_for_submission( + &text, + &text_elements, + ); + self.textarea.set_text_clearing_elements(""); + return ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); + // Inserted prompt text is plain input; discard any elements. + self.textarea.set_text_clearing_elements(&text); self.textarea.set_cursor(target); return (InputResult::None, true); } @@ -886,7 +1019,7 @@ impl ChatComposer { /// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. #[inline] - fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { + fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) { if self.disable_paste_burst { // When burst detection is disabled, treat IME/non-ASCII input as normal typing. // In particular, do not retro-capture or buffer already-inserted prefix text. @@ -901,7 +1034,6 @@ impl ChatComposer { .. } = input { - let now = Instant::now(); if self.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } @@ -1145,6 +1277,111 @@ impl ChatComposer { lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } + fn trim_text_elements( + original: &str, + trimmed: &str, + elements: Vec, + ) -> Vec { + if trimmed.is_empty() || elements.is_empty() { + return Vec::new(); + } + let trimmed_start = original.len().saturating_sub(original.trim_start().len()); + let trimmed_end = trimmed_start.saturating_add(trimmed.len()); + + elements + .into_iter() + .filter_map(|elem| { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + if end <= trimmed_start || start >= trimmed_end { + return None; + } + let new_start = start.saturating_sub(trimmed_start); + let new_end = end.saturating_sub(trimmed_start).min(trimmed.len()); + if new_start >= new_end { + return None; + } + let placeholder = trimmed.get(new_start..new_end).map(str::to_string); + Some(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )) + }) + .collect() + } + + /// Expand large-paste placeholders using element ranges and rebuild other element spans. + fn expand_pending_pastes( + text: &str, + mut elements: Vec, + pending_pastes: &[(String, String)], + ) -> (String, Vec) { + if pending_pastes.is_empty() || elements.is_empty() { + return (text.to_string(), elements); + } + + // Stage 1: index pending paste payloads by placeholder for deterministic replacements. + let mut pending_by_placeholder: HashMap<&str, VecDeque<&str>> = HashMap::new(); + for (placeholder, actual) in pending_pastes { + pending_by_placeholder + .entry(placeholder.as_str()) + .or_default() + .push_back(actual.as_str()); + } + + // Stage 2: walk elements in order and rebuild text/spans in a single pass. + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut rebuilt = String::with_capacity(text.len()); + let mut rebuilt_elements = Vec::with_capacity(elements.len()); + let mut cursor = 0usize; + + for elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if start > end { + continue; + } + if start > cursor { + rebuilt.push_str(&text[cursor..start]); + } + let elem_text = &text[start..end]; + let placeholder = elem.placeholder(text).map(str::to_string); + let replacement = placeholder + .as_deref() + .and_then(|ph| pending_by_placeholder.get_mut(ph)) + .and_then(VecDeque::pop_front); + if let Some(actual) = replacement { + // Stage 3: inline actual paste payloads and drop their placeholder elements. + rebuilt.push_str(actual); + } else { + // Stage 4: keep non-paste elements, updating their byte ranges for the new text. + let new_start = rebuilt.len(); + rebuilt.push_str(elem_text); + let new_end = rebuilt.len(); + let placeholder = placeholder.or_else(|| Some(elem_text.to_string())); + rebuilt_elements.push(TextElement::new( + ByteRange { + start: new_start, + end: new_end, + }, + placeholder, + )); + } + cursor = end; + } + + // Stage 5: append any trailing text that followed the last element. + if cursor < text.len() { + rebuilt.push_str(&text[cursor..]); + } + + (rebuilt, rebuilt_elements) + } + fn skills_enabled(&self) -> bool { self.skills.as_ref().is_some_and(|s| !s.is_empty()) } @@ -1332,7 +1569,8 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Path replacement is plain text; rebuild without carrying elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } @@ -1367,44 +1605,43 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - self.textarea.set_text(&new_text); + // Skill insertion rebuilds plain text, so drop existing elements. + self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } /// Prepare text for submission/queuing. Returns None if submission should be suppressed. - fn prepare_submission_text(&mut self) -> Option { - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - + /// On success, clears pending paste payloads because placeholders have been expanded. + fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { let mut text = self.textarea.text().to_string(); let original_input = text.clone(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); + let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } + if !self.pending_pastes.is_empty() { + // Expand placeholders so element byte ranges stay aligned. + let (expanded, expanded_elements) = + Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + text = expanded; + text_elements = expanded_elements; } - self.pending_pastes.clear(); + + let expanded_input = text.clone(); // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); + text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements); - if let Some((name, _rest)) = parse_slash_name(&text) { + if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { let is_builtin = Self::built_in_slash_commands_for_input( @@ -1428,39 +1665,63 @@ impl ChatComposer { self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event(message, None), ))); - self.textarea.set_text(&original_input); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); self.textarea.set_cursor(original_input.len()); return None; } } } - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; + let expanded_prompt = + match expand_custom_prompt(&text, &text_elements, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.set_text_content( + original_input.clone(), + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } + }; if let Some(expanded) = expanded_prompt { - text = expanded; + text = expanded.text; + text_elements = expanded.text_elements; } - if text.is_empty() && !has_attachments { + // Custom prompt expansion can remove or rewrite image placeholders, so prune any + // attachments that no longer have a corresponding placeholder in the expanded text. + self.prune_attached_images_for_submission(&text, &text_elements); + if text.is_empty() && self.attached_images.is_empty() { return None; } if !text.is_empty() { self.history.record_local_submission(&text); } - Some(text) + self.pending_pastes.clear(); + Some((text, text_elements)) } /// Common logic for handling message submission/queuing. /// Returns the appropriate InputResult based on `should_queue`. fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { + self.handle_submission_with_time(should_queue, Instant::now()) + } + + fn handle_submission_with_time( + &mut self, + should_queue: bool, + now: Instant, + ) -> (InputResult, bool) { // If the first line is a bare built-in slash command (no args), // dispatch it even when the slash popup isn't visible. This preserves // the workflow: type a prefix ("/di"), press Tab to complete to @@ -1483,15 +1744,15 @@ impl ChatComposer { .next() .unwrap_or("") .starts_with('/'); - if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context { - let now = Instant::now(); - if self.paste_burst.append_newline_if_active(now) { - return (InputResult::None, true); - } + if !self.disable_paste_burst + && self.paste_burst.is_active() + && !in_slash_context + && self.paste_burst.append_newline_if_active(now) + { + return (InputResult::None, true); } // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. - let now = Instant::now(); if !in_slash_context && !self.disable_paste_burst && self @@ -1504,20 +1765,44 @@ impl ChatComposer { } let original_input = self.textarea.text().to_string(); + let original_text_elements = self.textarea.text_elements(); + let original_local_image_paths = self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect::>(); + let original_pending_pastes = self.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } - if let Some(text) = self.prepare_submission_text() { + if let Some((text, text_elements)) = self.prepare_submission_text() { if should_queue { - (InputResult::Queued(text), true) + ( + InputResult::Queued { + text, + text_elements, + }, + true, + ) } else { // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) + ( + InputResult::Submitted { + text, + text_elements, + }, + true, + ) } } else { - // Restore text if submission was suppressed - self.textarea.set_text(&original_input); + // Restore text if submission was suppressed. + self.set_text_content( + original_input, + original_text_elements, + original_local_image_paths, + ); + self.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -1526,7 +1811,7 @@ impl ChatComposer { /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) + if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line) && rest.is_empty() && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( self.collaboration_modes_enabled, @@ -1534,7 +1819,7 @@ impl ChatComposer { ) .find(|(n, _)| *n == name) { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); Some(InputResult::Command(cmd)) } else { None @@ -1549,7 +1834,7 @@ impl ChatComposer { if !input_starts_with_space { let text = self.textarea.text().to_string(); - if let Some((name, rest)) = parse_slash_name(&text) + if let Some((name, rest, _rest_offset)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( @@ -1559,7 +1844,7 @@ impl ChatComposer { .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { - self.textarea.set_text(""); + self.textarea.set_text_clearing_elements(""); return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); } } @@ -1615,7 +1900,7 @@ impl ChatComposer { _ => unreachable!(), }; if let Some(text) = replace_text { - self.set_text_content(text); + self.set_text_content(text, Vec::new(), Vec::new()); return (InputResult::None, true); } } @@ -1626,7 +1911,7 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, .. - } => self.handle_submission(true), + } if self.is_task_running => self.handle_submission(true), KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -1679,9 +1964,16 @@ impl ChatComposer { /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a /// timestamp to time out against. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + self.handle_input_basic_with_time(input, Instant::now()) + } + + fn handle_input_basic_with_time( + &mut self, + input: KeyEvent, + now: Instant, + ) -> (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(); self.handle_paste_burst_flush(now); if !matches!(input.code, KeyCode::Esc) { @@ -1713,7 +2005,7 @@ impl ChatComposer { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid // holding the first char while still allowing burst detection for paste input. if !ch.is_ascii() { - return self.handle_non_ascii_char(input); + return self.handle_non_ascii_char(input, now); } match self.paste_burst.on_plain_char(ch, now) { @@ -2393,6 +2685,7 @@ fn prompt_selection_action( prompt: &CustomPrompt, first_line: &str, mode: PromptSelectionMode, + text_elements: &[TextElement], ) -> PromptSelectionAction { let named_args = prompt_argument_names(&prompt.content); let has_numeric = prompt_has_numeric_placeholders(&prompt.content); @@ -2424,14 +2717,21 @@ fn prompt_selection_action( }; } if has_numeric { - if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - return PromptSelectionAction::Submit { text: expanded }; + if let Some(expanded) = + expand_if_numeric_with_positional_args(prompt, first_line, text_elements) + { + return PromptSelectionAction::Submit { + text: expanded.text, + text_elements: expanded.text_elements, + }; } let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); return PromptSelectionAction::Insert { text, cursor: None }; } PromptSelectionAction::Submit { text: prompt.content.clone(), + // By now we know this custom prompt has no args, so no text elements to preserve. + text_elements: Vec::new(), } } } @@ -2453,6 +2753,7 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; + use crate::bottom_pane::prompt_args::PromptArg; use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -2715,11 +3016,8 @@ mod tests { composer.set_steer_enabled(true); composer.set_steer_enabled(true); composer.set_steer_enabled(true); - composer.set_steer_enabled(true); - composer.set_steer_enabled(true); - composer.set_steer_enabled(true); - composer.set_text_content("draft text".to_string()); + composer.set_text_content("draft text".to_string(), Vec::new(), Vec::new()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); assert!(composer.is_empty()); @@ -3026,7 +3324,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted { text, .. } => assert_eq!(text, "1あ"), _ => panic!("expected Submitted"), } } @@ -3198,26 +3496,38 @@ mod tests { composer.set_steer_enabled(true); composer.set_steer_enabled(true); - // Force an active burst so this test doesn't depend on tight timing. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); + let mut now = Instant::now(); + let step = Duration::from_millis(1); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + now, + ); + now += step; + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + now, + ); + now += step; - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let (result, _) = composer.handle_submission_with_time(!composer.steer_enabled, now); assert!( matches!(result, InputResult::None), "Enter during a burst should insert newline, not submit" ); for ch in ['t', 'h', 'e', 'r', 'e'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + now += step; + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); } - let _ = flush_after_paste_burst(&mut composer); + assert!(composer.textarea.text().is_empty()); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); + assert!(flushed, "expected paste burst to flush"); assert_eq!(composer.textarea.text(), "hi\nthere"); } @@ -3240,7 +3550,7 @@ mod tests { false, ); - composer.textarea.set_text("/diff"); + composer.textarea.set_text_clearing_elements("/diff"); composer.textarea.set_cursor("/diff".len()); composer .paste_burst @@ -3348,7 +3658,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted { text, .. } => assert_eq!(text, "hello"), _ => panic!("expected Submitted"), } } @@ -3414,7 +3724,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -3687,10 +3997,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3703,15 +4013,37 @@ mod tests { let args = extract_positional_args_for_prompt_line( "/prompts:review \"docs/My File.md\"", "review", + &[], + ); + assert_eq!( + args, + vec![PromptArg { + text: "docs/My File.md".to_string(), + text_elements: Vec::new(), + }] ); - assert_eq!(args, vec!["docs/My File.md".to_string()]); } #[test] fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = - extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); - assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); + let args = extract_positional_args_for_prompt_line( + "/prompts:cmd \"with spaces\" simple", + "cmd", + &[], + ); + assert_eq!( + args, + vec![ + PromptArg { + text: "with spaces".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: "simple".to_string(), + text_elements: Vec::new(), + } + ] + ); } #[test] @@ -3766,10 +4098,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch after Tab completion, got literal queue") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3805,10 +4137,10 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { panic!("expected command dispatch, but composer submitted literal text: {text}") } - InputResult::Queued(_) => { + InputResult::Queued { .. } => { panic!("expected command dispatch, but composer queued literal text") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3893,7 +4225,7 @@ mod tests { // Submit and verify final expansion let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); } else { panic!("expected Submitted"); @@ -4090,7 +4422,7 @@ mod tests { composer.textarea.text().contains(&placeholder), composer.pending_pastes.len(), ); - composer.textarea.set_text(""); + composer.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -4106,7 +4438,7 @@ mod tests { // --- Image attachment tests --- #[test] - fn attach_image_and_submit_includes_image_paths() { + fn attach_image_and_submit_includes_local_image_paths() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -4123,13 +4455,228 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1] hi"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); assert_eq!(vec![path], imgs); } + #[test] + fn set_text_content_reattaches_images_without_placeholder_metadata() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = local_image_label_text(1); + let text = format!("{placeholder} restored"); + let text_elements = vec![TextElement::new((0..placeholder.len()).into(), None)]; + let path = PathBuf::from("/tmp/image1.png"); + + composer.set_text_content(text, text_elements, vec![path.clone()]); + + assert_eq!(composer.local_image_paths(), vec![path]); + } + + #[test] + fn large_paste_preserves_image_text_elements_on_submit() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_paste.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let expected = format!("{large_content} [Image #1]"); + assert_eq!(text, expected); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: large_content.len() + 1, + end: large_content.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn large_paste_with_leading_whitespace_trims_and_shifts_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let large_content = format!(" {}", "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5)); + composer.handle_paste(large_content.clone()); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_with_trim.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let trimmed = large_content.trim().to_string(); + assert_eq!(text, format!("{trimmed} [Image #1]")); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: trimmed.len() + 1, + end: trimmed.len() + 1 + "[Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn pasted_crlf_normalizes_newlines_for_elements() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + let pasted = "line1\r\nline2\r\n".to_string(); + composer.handle_paste(pasted); + composer.handle_paste(" ".into()); + let path = PathBuf::from("/tmp/image_crlf.png"); + composer.attach_image(path.clone()); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "line1\nline2\n [Image #1]"); + assert!(!text.contains('\r')); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: "line1\nline2\n ".len(), + end: "line1\nline2\n [Image #1]".len(), + } + ); + } + _ => panic!("expected Submitted"), + } + let imgs = composer.take_recent_submission_images(); + assert_eq!(vec![path], imgs); + } + + #[test] + fn suppressed_submission_restores_pending_paste_payload() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.textarea.set_text_clearing_elements("/unknown "); + composer.textarea.set_cursor("/unknown ".len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + let placeholder = composer + .pending_pastes + .first() + .expect("expected pending paste") + .0 + .clone(); + + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(matches!(result, InputResult::None)); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + + composer.textarea.set_cursor(0); + composer.textarea.insert_str(" "); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("/unknown {large_content}")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.pending_pastes.is_empty()); + } + #[test] fn attach_image_without_text_submits_empty_text_and_images() { let (tx, _rx) = unbounded_channel::(); @@ -4147,7 +4694,21 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "[Image #1]"); + assert_eq!(text_elements.len(), 1); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + assert_eq!( + text_elements[0].byte_range, + ByteRange { + start: 0, + end: "[Image #1]".len() + } + ); + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -4302,6 +4863,69 @@ mod tests { ); } + #[test] + fn deleting_reordered_image_one_renumbers_text_in_place() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let path1 = PathBuf::from("/tmp/image_first.png"); + let path2 = PathBuf::from("/tmp/image_second.png"); + let placeholder1 = local_image_label_text(1); + let placeholder2 = local_image_label_text(2); + + // Placeholders can be reordered in the text buffer; deleting image #1 should renumber + // image #2 wherever it appears, not just after the cursor. + let text = format!("Test {placeholder2} test {placeholder1}"); + let start2 = text.find(&placeholder2).expect("placeholder2 present"); + let start1 = text.find(&placeholder1).expect("placeholder1 present"); + let text_elements = vec![ + TextElement::new( + ByteRange { + start: start2, + end: start2 + placeholder2.len(), + }, + Some(placeholder2), + ), + TextElement::new( + ByteRange { + start: start1, + end: start1 + placeholder1.len(), + }, + Some(placeholder1.clone()), + ), + ]; + composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); + + let end1 = start1 + placeholder1.len(); + composer.textarea.set_cursor(end1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + + assert_eq!( + composer.textarea.text(), + format!("Test {placeholder1} test ") + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: placeholder1 + }], + composer.attached_images, + "attachment renumbered after deletion" + ); + } + #[test] fn deleting_first_text_element_renumbers_following_text_element() { use crossterm::event::KeyCode; @@ -4399,7 +5023,10 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == prompt_text + )); assert!(composer.textarea.is_empty()); } @@ -4426,15 +5053,16 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice BRANCH=main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Review Alice changes on main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Review Alice changes on main" + )); assert!(composer.textarea.is_empty()); } @@ -4461,18 +5089,179 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + .set_text_clearing_elements("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Pair Alice Smith with dev-main" + )); assert!(composer.textarea.is_empty()); } + #[test] + fn custom_prompt_submission_preserves_image_placeholder_unquoted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt.png"); + composer.attach_image(path); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn custom_prompt_submission_preserves_image_placeholder_quoted() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG=\""); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt_quoted.png"); + composer.attach_image(path); + composer.handle_paste("\"".to_string()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + + #[test] + fn custom_prompt_submission_drops_unused_image_arg() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review changes".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/unused_image.png"); + composer.attach_image(path); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, "Review changes"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + assert!(composer.take_recent_submission_images().is_empty()); + } + /// Behavior: selecting a custom prompt that includes a large paste placeholder should expand /// to the full pasted content before submission. #[test] @@ -4503,7 +5292,7 @@ mod tests { // Type the slash command let command_text = "/prompts:code-review "; - composer.textarea.set_text(command_text); + composer.textarea.set_text_clearing_elements(command_text); composer.textarea.set_cursor(command_text.len()); // Paste large content (>3000 chars) to trigger placeholder @@ -4526,7 +5315,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted { text, .. } => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, @@ -4540,6 +5329,65 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + #[test] + fn custom_prompt_with_large_paste_and_image_preserves_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $IMG\n\n$CODE".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt IMG="); + composer.textarea.set_cursor(composer.textarea.text().len()); + let path = PathBuf::from("/tmp/image_prompt_combo.png"); + composer.attach_image(path); + composer.handle_paste(" CODE=".to_string()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted { + text, + text_elements, + } => { + let placeholder = local_image_label_text(1); + assert_eq!(text, format!("Review {placeholder}\n\n{large_content}")); + assert_eq!( + text_elements, + vec![TextElement::new( + ByteRange { + start: "Review ".len(), + end: "Review ".len() + placeholder.len(), + }, + Some(placeholder), + )] + ); + } + _ => panic!("expected Submitted"), + } + } + #[test] fn slash_path_input_submits_without_command_error() { use crossterm::event::KeyCode; @@ -4559,12 +5407,12 @@ mod tests { composer .textarea - .set_text("/Users/example/project/src/main.rs"); + .set_text_clearing_elements("/Users/example/project/src/main.rs"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/Users/example/project/src/main.rs"); } else { panic!("expected Submitted"); @@ -4594,12 +5442,14 @@ mod tests { ); composer.set_steer_enabled(true); - composer.textarea.set_text(" /this-looks-like-a-command"); + composer + .textarea + .set_text_clearing_elements(" /this-looks-like-a-command"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { + if let InputResult::Submitted { text, .. } = result { assert_eq!(text, "/this-looks-like-a-command"); } else { panic!("expected Submitted"); @@ -4634,7 +5484,7 @@ mod tests { composer .textarea - .set_text("/prompts:my-prompt USER=Alice stray"); + .set_text_clearing_elements("/prompts:my-prompt USER=Alice stray"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4683,7 +5533,9 @@ mod tests { }]); // Provide only one of the required args - composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt USER=Alice"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -4748,7 +5600,205 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); + } + + #[test] + fn popup_prompt_submission_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello".to_string(), + description: None, + argument_hint: None, + }]); + + composer.attach_image(PathBuf::from("/tmp/unused.png")); + composer.textarea.set_cursor(0); + composer.handle_paste(format!("/{PROMPTS_CMD_PREFIX}:my-prompt ")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Hello" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn numeric_prompt_auto_submit_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello $1".to_string(), + description: None, + argument_hint: None, + }]); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', + 'p', 't', ' ', 'f', 'o', 'o', ' ', + ], + ); + composer.attach_image(PathBuf::from("/tmp/unused.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Hello foo" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn numeric_prompt_auto_submit_expands_pending_pastes() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Echo: $1".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt "); + composer.textarea.set_cursor(composer.textarea.text().len()); + let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + composer.handle_paste(large_content.clone()); + + assert_eq!(composer.pending_pastes.len(), 1); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let expected = format!("Echo: {large_content}"); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); + assert!(composer.pending_pastes.is_empty()); + } + + #[test] + fn queued_prompt_submission_prunes_unused_image_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(false); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Hello $1".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text_clearing_elements("/prompts:my-prompt foo "); + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.attach_image(PathBuf::from("/tmp/unused.png")); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Queued { text, .. } if text == "Hello foo" + )); + assert!( + composer + .take_recent_submission_images_with_placeholders() + .is_empty() + ); + } + + #[test] + fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() { + let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n"; + + let prompt = CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: prompt_text.to_string(), + description: None, + argument_hint: None, + }; + + let action = prompt_selection_action( + &prompt, + "/prompts:my-prompt foo bar", + PromptSelectionMode::Submit, + &[], + ); + match action { + PromptSelectionAction::Submit { + text, + text_elements, + } => { + assert_eq!(text, "Header: foo\nArgs: foo bar\n"); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submit action"), + } } #[test] @@ -4775,11 +5825,16 @@ mod tests { }]); // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/prompts:elegant hi"); + composer + .textarea + .set_text_clearing_elements("/prompts:elegant hi"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == "Echo: hi" + )); assert!(composer.textarea.is_empty()); } @@ -4851,10 +5906,11 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - InputResult::Submitted("Cost: $$ and first: x".to_string()), - result - ); + assert!(matches!( + result, + InputResult::Submitted { text, .. } + if text == "Cost: $$ and first: x" + )); } #[test] @@ -4891,7 +5947,10 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let expected = "First: one two\nSecond: one two".to_string(); - assert_eq!(InputResult::Submitted(expected), result); + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == expected + )); } /// Behavior: the first fast ASCII character is held briefly to avoid flicker; if no burst @@ -4942,9 +6001,13 @@ mod tests { ); let count = 32; + let mut now = Instant::now(); + let step = Duration::from_millis(1); for _ in 0..count { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + now, + ); assert!( composer.is_in_paste_burst(), "expected active paste burst during fast typing" @@ -4953,13 +6016,15 @@ mod tests { composer.textarea.text().is_empty(), "text should not appear during burst" ); + now += step; } assert!( composer.textarea.text().is_empty(), "text should remain empty until flush" ); - let flushed = flush_after_paste_burst(&mut composer); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected buffered text to flush after stop"); assert_eq!(composer.textarea.text(), "a".repeat(count)); assert!( @@ -4972,10 +6037,6 @@ mod tests { /// the payload is large, it should insert a placeholder and defer the full text until submit. #[test] fn burst_paste_fast_large_inserts_placeholder_on_flush() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -4987,14 +6048,20 @@ mod tests { ); let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder + let mut now = Instant::now(); + let step = Duration::from_millis(1); for _ in 0..count { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + now, + ); + now += step; } // Nothing should appear until we stop and flush assert!(composer.textarea.text().is_empty()); - let flushed = flush_after_paste_burst(&mut composer); + let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; + let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected flush after stopping fast input"); let expected_placeholder = format!("[Pasted Content {count} chars]"); @@ -5045,7 +6112,7 @@ mod tests { ); // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string()); + composer.set_text_content("/ test".to_string(), Vec::new(), Vec::new()); // After set_text_content -> sync_popups is called; popup should NOT be Command. assert!( @@ -5075,21 +6142,21 @@ mod tests { ); // Case 1: bare "/" - composer.set_text_content("/".to_string()); + composer.set_text_content("/".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "bare '/' should activate slash popup" ); // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string()); + composer.set_text_content("/re".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/re' should activate slash popup via prefix match" ); // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string()); + composer.set_text_content("/ac".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::Command(_)), "'/ac' should activate slash popup via fuzzy match" @@ -5098,7 +6165,7 @@ mod tests { // Case 4: invalid prefix "/zzz" – still allowed to open popup if it // matches no built-in command; our current logic will not open popup. // Verify that explicitly. - composer.set_text_content("/zzz".to_string()); + composer.set_text_content("/zzz".to_string(), Vec::new(), Vec::new()); assert!( matches!(composer.active_popup, ActivePopup::None), "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" @@ -5233,7 +6300,7 @@ mod tests { false, ); - composer.set_text_content("hello".to_string()); + composer.set_text_content("hello".to_string(), Vec::new(), Vec::new()); composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); let (result, needs_redraw) = diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 88224e06e2..0e336b3988 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -10,7 +10,6 @@ use crate::render::Insets; use crate::render::RectExt; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; -use codex_common::fuzzy_match::fuzzy_match; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; @@ -121,52 +120,84 @@ impl CommandPopup { measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) } - /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Preserves the original + /// Compute exact/prefix matches over built-in commands and user prompts, + /// paired with optional highlight indices. Preserves the original /// presentation order for built-ins and prompts. - fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { + fn filtered(&self) -> Vec<(CommandItem, Option>)> { let filter = self.command_filter.trim(); - let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); + let mut out: Vec<(CommandItem, Option>)> = Vec::new(); if filter.is_empty() { // Built-ins first, in presentation order. for (_, cmd) in self.builtins.iter() { - out.push((CommandItem::Builtin(*cmd), None, 0)); + out.push((CommandItem::Builtin(*cmd), None)); } // Then prompts, already sorted by name. for idx in 0..self.prompts.len() { - out.push((CommandItem::UserPrompt(idx), None, 0)); + out.push((CommandItem::UserPrompt(idx), None)); } return out; } + let filter_lower = filter.to_lowercase(); + let filter_chars = filter.chars().count(); + let mut exact: Vec<(CommandItem, Option>)> = Vec::new(); + let mut prefix: Vec<(CommandItem, Option>)> = Vec::new(); + let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1; + let indices_for = |offset| Some((offset..offset + filter_chars).collect()); + + let mut push_match = + |item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| { + let display_lower = display.to_lowercase(); + let name_lower = name.map(str::to_lowercase); + let display_exact = display_lower == filter_lower; + let name_exact = name_lower.as_deref() == Some(filter_lower.as_str()); + if display_exact || name_exact { + let offset = if display_exact { 0 } else { name_offset }; + exact.push((item, indices_for(offset))); + return; + } + let display_prefix = display_lower.starts_with(&filter_lower); + let name_prefix = name_lower + .as_ref() + .is_some_and(|name| name.starts_with(&filter_lower)); + if display_prefix || name_prefix { + let offset = if display_prefix { 0 } else { name_offset }; + prefix.push((item, indices_for(offset))); + } + }; + for (_, cmd) in self.builtins.iter() { - if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { - out.push((CommandItem::Builtin(*cmd), Some(indices), score)); - } + push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0); } // Support both search styles: // - Typing "name" should surface "/prompts:name" results. // - Typing "prompts:name" should also work. for (idx, p) in self.prompts.iter().enumerate() { let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); - if let Some((indices, score)) = fuzzy_match(&display, filter) { - out.push((CommandItem::UserPrompt(idx), Some(indices), score)); - } + push_match( + CommandItem::UserPrompt(idx), + &display, + Some(&p.name), + prompt_prefix_len, + ); } + + out.extend(exact); + out.extend(prefix); out } fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(c, _, _)| c).collect() + self.filtered().into_iter().map(|(c, _)| c).collect() } fn rows_from_matches( &self, - matches: Vec<(CommandItem, Option>, i32)>, + matches: Vec<(CommandItem, Option>)>, ) -> Vec { matches .into_iter() - .map(|(item, indices, _)| { + .map(|(item, indices)| { let (name, description) = match item { CommandItem::Builtin(cmd) => { (format!("/{}", cmd.command()), cmd.description().to_string()) @@ -288,7 +319,7 @@ mod tests { } #[test] - fn filtered_commands_keep_presentation_order() { + fn filtered_commands_keep_presentation_order_for_prefix() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/m".to_string()); @@ -300,17 +331,7 @@ mod tests { CommandItem::UserPrompt(_) => None, }) .collect(); - assert_eq!( - cmds, - vec![ - "model", - "experimental", - "resume", - "compact", - "mention", - "mcp" - ] - ); + assert_eq!(cmds, vec!["model", "mention", "mcp"]); } #[test] @@ -380,7 +401,7 @@ mod tests { }], CommandPopupFlags::default(), ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!( description, @@ -400,13 +421,13 @@ mod tests { }], CommandPopupFlags::default(), ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!(description, Some("send saved prompt")); } #[test] - fn fuzzy_filter_matches_subsequence_for_ac() { + fn prefix_filter_limits_matches_for_ac() { let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); popup.on_composer_text_change("/ac".to_string()); @@ -419,8 +440,8 @@ mod tests { }) .collect(); assert!( - cmds.contains(&"compact") && cmds.contains(&"feedback"), - "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" + !cmds.contains(&"compact"), + "expected prefix search for '/ac' to exclude 'compact', got {cmds:?}" ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3ba7d4a361..6eafb60678 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -29,6 +29,8 @@ use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; +use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -37,9 +39,17 @@ use ratatui::text::Line; use std::time::Duration; mod approval_overlay; +mod request_user_input; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; +pub(crate) use request_user_input::RequestUserInputOverlay; mod bottom_pane_view; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LocalImageAttachment { + pub(crate) placeholder: String, + pub(crate) path: PathBuf, +} mod chat_composer; mod chat_composer_history; mod command_popup; @@ -328,8 +338,14 @@ impl BottomPane { } /// Replace the composer text with `text`. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.composer.set_text_content(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.composer + .set_text_content(text, text_elements, local_image_paths); self.request_redraw(); } @@ -353,6 +369,19 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_text_elements(&self) -> Vec { + self.composer.text_elements() + } + + pub(crate) fn composer_local_images(&self) -> Vec { + self.composer.local_images() + } + + #[cfg(test)] + pub(crate) fn composer_local_image_paths(&self) -> Vec { + self.composer.local_image_paths() + } + pub(crate) fn composer_text_with_pending(&self) -> String { self.composer.current_text_with_pending() } @@ -597,8 +626,32 @@ impl BottomPane { self.push_view(Box::new(modal)); } + /// Called when the agent requests user input. + pub fn push_user_input_request(&mut self, request: RequestUserInputEvent) { + let request = if let Some(view) = self.view_stack.last_mut() { + match view.try_consume_user_input_request(request) { + Some(request) => request, + None => { + self.request_redraw(); + return; + } + } + } else { + request + }; + + let modal = RequestUserInputOverlay::new(request, self.app_event_tx.clone()); + self.pause_status_timer_for_modal(); + self.set_composer_input_enabled( + false, + Some("Answer the questions to continue.".to_string()), + ); + self.push_view(Box::new(modal)); + } + fn on_active_view_complete(&mut self) { self.resume_status_timer_after_modal(); + self.set_composer_input_enabled(true, None); } fn pause_status_timer_for_modal(&mut self) { @@ -663,10 +716,18 @@ impl BottomPane { } } + #[cfg(test)] pub(crate) fn take_recent_submission_images(&mut self) -> Vec { self.composer.take_recent_submission_images() } + pub(crate) fn take_recent_submission_images_with_placeholders( + &mut self, + ) -> Vec { + self.composer + .take_recent_submission_images_with_placeholders() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) @@ -678,11 +739,14 @@ impl BottomPane { if !self.unified_exec_footer.is_empty() { flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer)); } + let has_queued_messages = !self.queued_user_messages.messages.is_empty(); + let has_status_or_footer = + self.status.is_some() || !self.unified_exec_footer.is_empty(); + if has_queued_messages && has_status_or_footer { + flex.push(0, RenderableItem::Owned("".into())); + } flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() - || !self.unified_exec_footer.is_empty() - || !self.queued_user_messages.messages.is_empty() - { + if !has_queued_messages && has_status_or_footer { flex.push(0, RenderableItem::Owned("".into())); } let mut flex2 = FlexRenderable::new(); @@ -928,6 +992,60 @@ mod tests { ); } + #[test] + fn status_only_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area)); + } + + #[test] + fn status_with_details_and_queued_messages_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_task_running(true); + pane.update_status( + "Working".to_string(), + Some("First detail line\nSecond detail line".to_string()), + ); + pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); + + let width = 48; + let height = pane.desired_height(width); + let area = Rect::new(0, 0, width, height); + assert_snapshot!( + "status_with_details_and_queued_messages_snapshot", + render_snapshot(&pane, area) + ); + } + #[test] fn queued_messages_visible_when_status_hidden_snapshot() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 48c3cedfab..efe0a00713 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,5 +1,7 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement; use lazy_static::lazy_static; use regex_lite::Regex; use shlex::Shlex; @@ -57,28 +59,49 @@ impl PromptExpansionError { } /// Parse a first-line slash command of the form `/name `. -/// Returns `(name, rest_after_name)` if the line begins with `/` and contains -/// a non-empty name; otherwise returns `None`. -pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { +/// Returns `(name, rest_after_name, rest_offset)` if the line begins with `/` +/// and contains a non-empty name; otherwise returns `None`. +/// +/// `rest_offset` is the byte index into the original line where `rest_after_name` +/// starts after trimming leading whitespace (so `line[rest_offset..] == rest_after_name`). +pub fn parse_slash_name(line: &str) -> Option<(&str, &str, usize)> { let stripped = line.strip_prefix('/')?; - let mut name_end = stripped.len(); + let mut name_end_in_stripped = stripped.len(); for (idx, ch) in stripped.char_indices() { if ch.is_whitespace() { - name_end = idx; + name_end_in_stripped = idx; break; } } - let name = &stripped[..name_end]; + let name = &stripped[..name_end_in_stripped]; if name.is_empty() { return None; } - let rest = stripped[name_end..].trim_start(); - Some((name, rest)) + let rest_untrimmed = &stripped[name_end_in_stripped..]; + let rest = rest_untrimmed.trim_start(); + let rest_start_in_stripped = name_end_in_stripped + (rest_untrimmed.len() - rest.len()); + // `stripped` is `line` without the leading '/', so add 1 to get the original offset. + let rest_offset = rest_start_in_stripped + 1; + Some((name, rest, rest_offset)) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptArg { + pub text: String, + pub text_elements: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptExpansion { + pub text: String, + pub text_elements: Vec, } /// Parse positional arguments using shlex semantics (supports quoted tokens). -pub fn parse_positional_args(rest: &str) -> Vec { - Shlex::new(rest).collect() +/// +/// `text_elements` must be relative to `rest`. +pub fn parse_positional_args(rest: &str, text_elements: &[TextElement]) -> Vec { + parse_tokens_with_elements(rest, text_elements) } /// Extracts the unique placeholder variable names from a prompt template. @@ -106,25 +129,56 @@ pub fn prompt_argument_names(content: &str) -> Vec { names } +/// Shift a text element's byte range left by `offset`, returning `None` if empty. +/// +/// `offset` is the byte length of the prefix removed from the original text. +fn shift_text_element_left(elem: &TextElement, offset: usize) -> Option { + if elem.byte_range.end <= offset { + return None; + } + let start = elem.byte_range.start.saturating_sub(offset); + let end = elem.byte_range.end.saturating_sub(offset); + (start < end).then_some(elem.map_range(|_| ByteRange { start, end })) +} + /// Parses the `key=value` pairs that follow a custom prompt name. /// /// The input is split using shlex rules, so quoted values are supported /// (for example `USER="Alice Smith"`). The function returns a map of parsed /// arguments, or an error if a token is missing `=` or if the key is empty. -pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { +pub fn parse_prompt_inputs( + rest: &str, + text_elements: &[TextElement], +) -> Result, PromptArgsError> { let mut map = HashMap::new(); if rest.trim().is_empty() { return Ok(map); } - for token in Shlex::new(rest) { - let Some((key, value)) = token.split_once('=') else { - return Err(PromptArgsError::MissingAssignment { token }); + // Tokenize the rest of the command using shlex rules, but keep text element + // ranges relative to each emitted token. + for token in parse_tokens_with_elements(rest, text_elements) { + let Some((key, value)) = token.text.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token: token.text }); }; if key.is_empty() { - return Err(PromptArgsError::MissingKey { token }); + return Err(PromptArgsError::MissingKey { token: token.text }); } - map.insert(key.to_string(), value.to_string()); + // The token is `key=value`; translate element ranges into the value-only + // coordinate space by subtracting the `key=` prefix length. + let value_start = key.len() + 1; + let value_elements = token + .text_elements + .iter() + .filter_map(|elem| shift_text_element_left(elem, value_start)) + .collect(); + map.insert( + key.to_string(), + PromptArg { + text: value.to_string(), + text_elements: value_elements, + }, + ); } Ok(map) } @@ -136,9 +190,10 @@ pub fn parse_prompt_inputs(rest: &str) -> Result, Prompt /// `Ok(Some(expanded))`; otherwise it returns a descriptive error. pub fn expand_custom_prompt( text: &str, + text_elements: &[TextElement], custom_prompts: &[CustomPrompt], -) -> Result, PromptExpansionError> { - let Some((name, rest)) = parse_slash_name(text) else { +) -> Result, PromptExpansionError> { + let Some((name, rest, rest_offset)) = parse_slash_name(text) else { return Ok(None); }; @@ -153,10 +208,24 @@ pub fn expand_custom_prompt( }; // If there are named placeholders, expect key=value inputs. let required = prompt_argument_names(&prompt.content); + let local_elements: Vec = text_elements + .iter() + .filter_map(|elem| { + let mut shifted = shift_text_element_left(elem, rest_offset)?; + if shifted.byte_range.start >= rest.len() { + return None; + } + let end = shifted.byte_range.end.min(rest.len()); + shifted.byte_range.end = end; + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); if !required.is_empty() { - let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { - command: format!("/{name}"), - error, + let inputs = parse_prompt_inputs(rest, &local_elements).map_err(|error| { + PromptExpansionError::Args { + command: format!("/{name}"), + error, + } })?; let missing: Vec = required .into_iter() @@ -168,28 +237,19 @@ pub fn expand_custom_prompt( missing, }); } - let content = &prompt.content; - let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { - if let Some(matched) = caps.get(0) - && matched.start() > 0 - && content.as_bytes()[matched.start() - 1] == b'$' - { - return matched.as_str().to_string(); - } - let whole = &caps[0]; - let key = &whole[1..]; - inputs - .get(key) - .cloned() - .unwrap_or_else(|| whole.to_string()) - }); - return Ok(Some(replaced.into_owned())); + let (text, elements) = expand_named_placeholders_with_elements(&prompt.content, &inputs); + return Ok(Some(PromptExpansion { + text, + text_elements: elements, + })); } // Otherwise, treat it as numeric/positional placeholder prompt (or none). - let pos_args: Vec = Shlex::new(rest).collect(); - let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); - Ok(Some(expanded)) + let pos_args = parse_positional_args(rest, &local_elements); + Ok(Some(expand_numeric_placeholders( + &prompt.content, + &pos_args, + ))) } /// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. @@ -213,25 +273,42 @@ pub fn prompt_has_numeric_placeholders(content: &str) -> bool { /// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. /// Returns empty when the command name does not match or when there are no args. -pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { +pub fn extract_positional_args_for_prompt_line( + line: &str, + prompt_name: &str, + text_elements: &[TextElement], +) -> Vec { let trimmed = line.trim_start(); - let Some(rest) = trimmed.strip_prefix('/') else { + let trim_offset = line.len() - trimmed.len(); + let Some((name, rest, rest_offset)) = parse_slash_name(trimmed) else { return Vec::new(); }; // Require the explicit prompts prefix for custom prompt invocations. - let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { + let Some(after_prefix) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { return Vec::new(); }; - let mut parts = after_prefix.splitn(2, char::is_whitespace); - let cmd = parts.next().unwrap_or(""); - if cmd != prompt_name { + if after_prefix != prompt_name { return Vec::new(); } - let args_str = parts.next().unwrap_or("").trim(); + let rest_trimmed_start = rest.trim_start(); + let args_str = rest_trimmed_start.trim_end(); if args_str.is_empty() { return Vec::new(); } - parse_positional_args(args_str) + let args_offset = trim_offset + rest_offset + (rest.len() - rest_trimmed_start.len()); + let local_elements: Vec = text_elements + .iter() + .filter_map(|elem| { + let mut shifted = shift_text_element_left(elem, args_offset)?; + if shifted.byte_range.start >= args_str.len() { + return None; + } + let end = shifted.byte_range.end.min(args_str.len()); + shifted.byte_range.end = end; + (shifted.byte_range.start < shifted.byte_range.end).then_some(shifted) + }) + .collect(); + parse_positional_args(args_str, &local_elements) } /// If the prompt only uses numeric placeholders and the first line contains @@ -239,14 +316,15 @@ pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> pub fn expand_if_numeric_with_positional_args( prompt: &CustomPrompt, first_line: &str, -) -> Option { + text_elements: &[TextElement], +) -> Option { if !prompt_argument_names(&prompt.content).is_empty() { return None; } if !prompt_has_numeric_placeholders(&prompt.content) { return None; } - let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); + let args = extract_positional_args_for_prompt_line(first_line, &prompt.name, text_elements); if args.is_empty() { return None; } @@ -254,10 +332,10 @@ pub fn expand_if_numeric_with_positional_args( } /// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. -pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { +pub fn expand_numeric_placeholders(content: &str, args: &[PromptArg]) -> PromptExpansion { let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); let mut i = 0; - let mut cached_joined_args: Option = None; while let Some(off) = content[i..].find('$') { let j = i + off; out.push_str(&content[i..j]); @@ -272,8 +350,8 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { } b'1'..=b'9' => { let idx = (bytes[1] - b'1') as usize; - if let Some(val) = args.get(idx) { - out.push_str(val); + if let Some(arg) = args.get(idx) { + append_arg_with_elements(&mut out, &mut out_elements, arg); } i = j + 2; continue; @@ -283,8 +361,7 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { } if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { if !args.is_empty() { - let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); - out.push_str(joined); + append_joined_args_with_elements(&mut out, &mut out_elements, args); } i = j + 1 + "ARGUMENTS".len(); continue; @@ -293,7 +370,179 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { i = j + 1; } out.push_str(&content[i..]); - out + PromptExpansion { + text: out, + text_elements: out_elements, + } +} + +fn parse_tokens_with_elements(rest: &str, text_elements: &[TextElement]) -> Vec { + let mut elements = text_elements.to_vec(); + elements.sort_by_key(|elem| elem.byte_range.start); + // Keep element placeholders intact across shlex splitting by replacing + // each element range with a unique sentinel token first. + let (rest_for_shlex, replacements) = replace_text_elements_with_sentinels(rest, &elements); + Shlex::new(&rest_for_shlex) + .map(|token| apply_replacements_to_token(token, &replacements)) + .collect() +} + +#[derive(Debug, Clone)] +struct ElementReplacement { + sentinel: String, + text: String, + placeholder: Option, +} + +/// Replace each text element range with a unique sentinel token. +/// +/// The sentinel is chosen so it will survive shlex tokenization as a single word. +fn replace_text_elements_with_sentinels( + rest: &str, + elements: &[TextElement], +) -> (String, Vec) { + let mut out = String::with_capacity(rest.len()); + let mut replacements = Vec::new(); + let mut cursor = 0; + + for (idx, elem) in elements.iter().enumerate() { + let start = elem.byte_range.start; + let end = elem.byte_range.end; + out.push_str(&rest[cursor..start]); + let mut sentinel = format!("__CODEX_ELEM_{idx}__"); + // Ensure we never collide with user content so a sentinel can't be mistaken for text. + while rest.contains(&sentinel) { + sentinel.push('_'); + } + out.push_str(&sentinel); + replacements.push(ElementReplacement { + sentinel, + text: rest[start..end].to_string(), + placeholder: elem.placeholder(rest).map(str::to_string), + }); + cursor = end; + } + + out.push_str(&rest[cursor..]); + (out, replacements) +} + +/// Rehydrate a shlex token by swapping sentinels back to the original text +/// and rebuilding text element ranges relative to the resulting token. +fn apply_replacements_to_token(token: String, replacements: &[ElementReplacement]) -> PromptArg { + if replacements.is_empty() { + return PromptArg { + text: token, + text_elements: Vec::new(), + }; + } + + let mut out = String::with_capacity(token.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + + while cursor < token.len() { + let Some((offset, replacement)) = next_replacement(&token, cursor, replacements) else { + out.push_str(&token[cursor..]); + break; + }; + let start_in_token = cursor + offset; + out.push_str(&token[cursor..start_in_token]); + let start = out.len(); + out.push_str(&replacement.text); + let end = out.len(); + if start < end { + out_elements.push(TextElement::new( + ByteRange { start, end }, + replacement.placeholder.clone(), + )); + } + cursor = start_in_token + replacement.sentinel.len(); + } + + PromptArg { + text: out, + text_elements: out_elements, + } +} + +/// Find the earliest sentinel occurrence at or after `cursor`. +fn next_replacement<'a>( + token: &str, + cursor: usize, + replacements: &'a [ElementReplacement], +) -> Option<(usize, &'a ElementReplacement)> { + let slice = &token[cursor..]; + let mut best: Option<(usize, &'a ElementReplacement)> = None; + for replacement in replacements { + if let Some(pos) = slice.find(&replacement.sentinel) { + match best { + Some((best_pos, _)) if best_pos <= pos => {} + _ => best = Some((pos, replacement)), + } + } + } + best +} + +fn expand_named_placeholders_with_elements( + content: &str, + args: &HashMap, +) -> (String, Vec) { + let mut out = String::with_capacity(content.len()); + let mut out_elements = Vec::new(); + let mut cursor = 0; + for m in PROMPT_ARG_REGEX.find_iter(content) { + let start = m.start(); + let end = m.end(); + if start > 0 && content.as_bytes()[start - 1] == b'$' { + out.push_str(&content[cursor..end]); + cursor = end; + continue; + } + out.push_str(&content[cursor..start]); + cursor = end; + let key = &content[start + 1..end]; + if let Some(arg) = args.get(key) { + append_arg_with_elements(&mut out, &mut out_elements, arg); + } else { + out.push_str(&content[start..end]); + } + } + out.push_str(&content[cursor..]); + (out, out_elements) +} + +fn append_arg_with_elements( + out: &mut String, + out_elements: &mut Vec, + arg: &PromptArg, +) { + let start = out.len(); + out.push_str(&arg.text); + if arg.text_elements.is_empty() { + return; + } + out_elements.extend(arg.text_elements.iter().map(|elem| { + elem.map_range(|range| ByteRange { + start: start + range.start, + end: start + range.end, + }) + })); +} + +fn append_joined_args_with_elements( + out: &mut String, + out_elements: &mut Vec, + args: &[PromptArg], +) { + // `$ARGUMENTS` joins args with single spaces while preserving element ranges. + for (idx, arg) in args.iter().enumerate() { + if idx > 0 { + out.push(' '); + } + append_arg_with_elements(out, out_elements, arg); + } } /// Constructs a command text for a custom prompt with arguments. @@ -313,6 +562,7 @@ pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (Str #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn expand_arguments_basic() { @@ -324,9 +574,15 @@ mod tests { argument_hint: None, }]; - let out = - expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); - assert_eq!(out, Some("Review Alice changes on main".to_string())); + let out = expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &[], &prompts) + .unwrap(); + assert_eq!( + out, + Some(PromptExpansion { + text: "Review Alice changes on main".to_string(), + text_elements: Vec::new(), + }) + ); } #[test] @@ -341,10 +597,17 @@ mod tests { let out = expand_custom_prompt( "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &[], &prompts, ) .unwrap(); - assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); + assert_eq!( + out, + Some(PromptExpansion { + text: "Pair Alice Smith with dev-main".to_string(), + text_elements: Vec::new(), + }) + ); } #[test] @@ -356,7 +619,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &[], &prompts) .unwrap_err() .user_message(); assert!(err.contains("expected key=value")); @@ -371,7 +634,7 @@ mod tests { description: None, argument_hint: None, }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &[], &prompts) .unwrap_err() .user_message(); assert!(err.to_lowercase().contains("missing required args")); @@ -400,7 +663,192 @@ mod tests { argument_hint: None, }]; - let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); - assert_eq!(out, Some("literal $$USER".to_string())); + let out = expand_custom_prompt("/prompts:my-prompt", &[], &prompts).unwrap(); + assert_eq!( + out, + Some(PromptExpansion { + text: "literal $$USER".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn positional_args_treat_placeholder_with_spaces_as_single_token() { + let placeholder = "[Image #1]"; + let rest = format!("alpha {placeholder} beta"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_positional_args(&rest, &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn extract_positional_args_shifts_element_offsets_into_args_str() { + let placeholder = "[Image #1]"; + let line = format!(" /{PROMPTS_CMD_PREFIX}:my-prompt alpha {placeholder} beta "); + let start = line.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = extract_positional_args_for_prompt_line(&line, "my-prompt", &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn key_value_args_treat_placeholder_with_spaces_as_single_token() { + let placeholder = "[Image #1]"; + let rest = format!("IMG={placeholder} NOTE=hello"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); + assert_eq!( + args.get("IMG"), + Some(&PromptArg { + text: placeholder.to_string(), + text_elements: vec![TextElement::new( + ByteRange { + start: 0, + end: placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }) + ); + assert_eq!( + args.get("NOTE"), + Some(&PromptArg { + text: "hello".to_string(), + text_elements: Vec::new(), + }) + ); + } + + #[test] + fn positional_args_allow_placeholder_inside_quotes() { + let placeholder = "[Image #1]"; + let rest = format!("alpha \"see {placeholder} here\" beta"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_positional_args(&rest, &text_elements); + assert_eq!( + args, + vec![ + PromptArg { + text: "alpha".to_string(), + text_elements: Vec::new(), + }, + PromptArg { + text: format!("see {placeholder} here"), + text_elements: vec![TextElement::new( + ByteRange { + start: "see ".len(), + end: "see ".len() + placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }, + PromptArg { + text: "beta".to_string(), + text_elements: Vec::new(), + } + ] + ); + } + + #[test] + fn key_value_args_allow_placeholder_inside_quotes() { + let placeholder = "[Image #1]"; + let rest = format!("IMG=\"see {placeholder} here\" NOTE=ok"); + let start = rest.find(placeholder).expect("placeholder"); + let end = start + placeholder.len(); + let text_elements = vec![TextElement::new( + ByteRange { start, end }, + Some(placeholder.to_string()), + )]; + + let args = parse_prompt_inputs(&rest, &text_elements).expect("inputs"); + assert_eq!( + args.get("IMG"), + Some(&PromptArg { + text: format!("see {placeholder} here"), + text_elements: vec![TextElement::new( + ByteRange { + start: "see ".len(), + end: "see ".len() + placeholder.len(), + }, + Some(placeholder.to_string()), + )], + }) + ); + assert_eq!( + args.get("NOTE"), + Some(&PromptArg { + text: "ok".to_string(), + text_elements: Vec::new(), + }) + ); } } diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs new file mode 100644 index 0000000000..fd5066c994 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -0,0 +1,151 @@ +use ratatui::layout::Rect; + +use super::RequestUserInputOverlay; + +pub(super) struct LayoutSections { + pub(super) progress_area: Rect, + pub(super) header_area: Rect, + pub(super) question_area: Rect, + pub(super) answer_title_area: Rect, + // Wrapped question text lines to render in the question area. + pub(super) question_lines: Vec, + pub(super) options_area: Rect, + pub(super) notes_title_area: Rect, + pub(super) notes_area: Rect, + // Number of footer rows (status + hints). + pub(super) footer_lines: u16, +} + +impl RequestUserInputOverlay { + /// Compute layout sections, collapsing notes and hints as space shrinks. + pub(super) fn layout_sections(&self, area: Rect) -> LayoutSections { + let question_lines = self + .current_question() + .map(|q| { + textwrap::wrap(&q.question, area.width.max(1) as usize) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + }) + .unwrap_or_default(); + let question_text_height = question_lines.len() as u16; + let has_options = self.has_options(); + let mut notes_input_height = self.notes_input_height(area.width); + // Keep the question + options visible first; notes and hints collapse as space shrinks. + let footer_lines = if self.unanswered_count() > 0 { 2 } else { 1 }; + let mut notes_title_height = if has_options { 1 } else { 0 }; + + let mut cursor_y = area.y; + let progress_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + cursor_y = cursor_y.saturating_add(1); + let header_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: 1, + }; + cursor_y = cursor_y.saturating_add(1); + let question_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: question_text_height, + }; + cursor_y = cursor_y.saturating_add(question_text_height); + // Remaining height after progress/header/question areas. + let remaining = area.height.saturating_sub(cursor_y.saturating_sub(area.y)); + let mut answer_title_height = if has_options { 1 } else { 0 }; + let mut options_height = 0; + if has_options { + let remaining_content = remaining.saturating_sub(footer_lines); + let options_len = self.options_len() as u16; + if remaining_content == 0 { + answer_title_height = 0; + notes_title_height = 0; + notes_input_height = 0; + options_height = 0; + } else { + let min_notes = 1u16; + let full_notes = 3u16; + // Prefer to keep all options visible, then allocate notes height. + if remaining_content + >= options_len + answer_title_height + notes_title_height + full_notes + { + let max_notes = remaining_content + .saturating_sub(options_len) + .saturating_sub(answer_title_height) + .saturating_sub(notes_title_height); + notes_input_height = notes_input_height.min(max_notes).max(full_notes); + } else if remaining_content > options_len + answer_title_height + min_notes { + notes_title_height = 0; + notes_input_height = min_notes; + } else { + // Tight layout: hide section titles and shrink notes to one line. + answer_title_height = 0; + notes_title_height = 0; + notes_input_height = min_notes; + } + + // Reserve notes/answer title area so options are scrollable if needed. + let reserved = answer_title_height + .saturating_add(notes_title_height) + .saturating_add(notes_input_height); + options_height = remaining_content.saturating_sub(reserved); + } + } else { + let max_notes = remaining.saturating_sub(footer_lines); + if max_notes == 0 { + notes_input_height = 0; + } else { + // When no options exist, notes are the primary input. + notes_input_height = notes_input_height.min(max_notes).max(3.min(max_notes)); + } + } + + let answer_title_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: answer_title_height, + }; + cursor_y = cursor_y.saturating_add(answer_title_height); + let options_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: options_height, + }; + cursor_y = cursor_y.saturating_add(options_height); + + let notes_title_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: notes_title_height, + }; + cursor_y = cursor_y.saturating_add(notes_title_height); + let notes_area = Rect { + x: area.x, + y: cursor_y, + width: area.width, + height: notes_input_height, + }; + + LayoutSections { + progress_area, + header_area, + question_area, + answer_title_area, + question_lines, + options_area, + notes_title_area, + notes_area, + footer_lines, + } + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs new file mode 100644 index 0000000000..a01aa2296b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -0,0 +1,770 @@ +//! Request-user-input overlay state machine. +//! +//! Core behaviors: +//! - Each question can be answered by selecting one option and/or providing notes. +//! - When options exist, notes are stored per selected option and appended as extra answers. +//! - Typing while focused on options jumps into notes to keep freeform input fast. +//! - Enter advances to the next question; the last question submits all answers. +//! - Freeform-only questions submit an empty answer list when empty. +use std::cell::RefCell; +use std::collections::HashMap; +use std::collections::VecDeque; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +mod layout; +mod render; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::bottom_pane_view::BottomPaneView; +use crate::bottom_pane::scroll_state::ScrollState; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; + +use codex_core::protocol::Op; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::request_user_input::RequestUserInputResponse; + +const NOTES_PLACEHOLDER: &str = "Add notes (optional)"; +const ANSWER_PLACEHOLDER: &str = "Type your answer (optional)"; +const SELECT_OPTION_PLACEHOLDER: &str = "Select an option to add notes (optional)"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Focus { + Options, + Notes, +} + +struct NotesEntry { + text: TextArea, + state: RefCell, +} + +impl NotesEntry { + fn new() -> Self { + Self { + text: TextArea::new(), + state: RefCell::new(TextAreaState::default()), + } + } +} + +struct AnswerState { + // Final selection for the question (always set for option questions). + selected: Option, + // Scrollable cursor state for option navigation/highlight. + option_state: ScrollState, + // Notes for freeform-only questions. + notes: NotesEntry, + // Per-option notes for option questions. + option_notes: Vec, +} + +pub(crate) struct RequestUserInputOverlay { + app_event_tx: AppEventSender, + request: RequestUserInputEvent, + // Queue of incoming requests to process after the current one. + queue: VecDeque, + answers: Vec, + current_idx: usize, + focus: Focus, + done: bool, +} + +impl RequestUserInputOverlay { + pub(crate) fn new(request: RequestUserInputEvent, app_event_tx: AppEventSender) -> Self { + let mut overlay = Self { + app_event_tx, + request, + queue: VecDeque::new(), + answers: Vec::new(), + current_idx: 0, + focus: Focus::Options, + done: false, + }; + overlay.reset_for_request(); + overlay.ensure_focus_available(); + overlay + } + + fn current_index(&self) -> usize { + self.current_idx + } + + fn current_question( + &self, + ) -> Option<&codex_protocol::request_user_input::RequestUserInputQuestion> { + self.request.questions.get(self.current_index()) + } + + fn current_answer_mut(&mut self) -> Option<&mut AnswerState> { + let idx = self.current_index(); + self.answers.get_mut(idx) + } + + fn current_answer(&self) -> Option<&AnswerState> { + let idx = self.current_index(); + self.answers.get(idx) + } + + fn question_count(&self) -> usize { + self.request.questions.len() + } + + fn has_options(&self) -> bool { + self.current_question() + .and_then(|question| question.options.as_ref()) + .is_some_and(|options| !options.is_empty()) + } + + fn options_len(&self) -> usize { + self.current_question() + .and_then(|question| question.options.as_ref()) + .map(std::vec::Vec::len) + .unwrap_or(0) + } + + fn selected_option_index(&self) -> Option { + if !self.has_options() { + return None; + } + self.current_answer() + .and_then(|answer| answer.selected.or(answer.option_state.selected_idx)) + } + + fn current_option_label(&self) -> Option<&str> { + let idx = self.selected_option_index()?; + self.current_question() + .and_then(|question| question.options.as_ref()) + .and_then(|options| options.get(idx)) + .map(|option| option.label.as_str()) + } + + fn current_notes_entry(&self) -> Option<&NotesEntry> { + let answer = self.current_answer()?; + if !self.has_options() { + return Some(&answer.notes); + } + let idx = self + .selected_option_index() + .or(answer.option_state.selected_idx)?; + answer.option_notes.get(idx) + } + + fn current_notes_entry_mut(&mut self) -> Option<&mut NotesEntry> { + let has_options = self.has_options(); + let answer = self.current_answer_mut()?; + if !has_options { + return Some(&mut answer.notes); + } + let idx = answer + .selected + .or(answer.option_state.selected_idx) + .or_else(|| answer.option_notes.is_empty().then_some(0))?; + answer.option_notes.get_mut(idx) + } + + fn notes_placeholder(&self) -> &'static str { + if self.has_options() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_none()) + { + SELECT_OPTION_PLACEHOLDER + } else if self.has_options() { + NOTES_PLACEHOLDER + } else { + ANSWER_PLACEHOLDER + } + } + + /// Ensure the focus mode is valid for the current question. + fn ensure_focus_available(&mut self) { + if self.question_count() == 0 { + return; + } + if !self.has_options() { + self.focus = Focus::Notes; + } + } + + /// Rebuild local answer state from the current request. + fn reset_for_request(&mut self) { + self.answers = self + .request + .questions + .iter() + .map(|question| { + let mut option_state = ScrollState::new(); + let mut option_notes = Vec::new(); + if let Some(options) = question.options.as_ref() + && !options.is_empty() + { + option_state.selected_idx = Some(0); + option_notes = (0..options.len()).map(|_| NotesEntry::new()).collect(); + } + AnswerState { + selected: option_state.selected_idx, + option_state, + notes: NotesEntry::new(), + option_notes, + } + }) + .collect(); + + self.current_idx = 0; + self.focus = Focus::Options; + } + + /// Move to the next/previous question, wrapping in either direction. + fn move_question(&mut self, next: bool) { + let len = self.question_count(); + if len == 0 { + return; + } + let offset = if next { 1 } else { len.saturating_sub(1) }; + self.current_idx = (self.current_idx + offset) % len; + self.ensure_focus_available(); + } + + /// Synchronize selection state to the currently focused option. + fn select_current_option(&mut self) { + if !self.has_options() { + return; + } + let options_len = self.options_len(); + let Some(answer) = self.current_answer_mut() else { + return; + }; + answer.option_state.clamp_selection(options_len); + answer.selected = answer.option_state.selected_idx; + } + + /// Ensure there is a selection before allowing notes entry. + fn ensure_selected_for_notes(&mut self) { + if self.has_options() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_none()) + { + self.select_current_option(); + } + } + + /// Advance to next question, or submit when on the last one. + fn go_next_or_submit(&mut self) { + if self.current_index() + 1 >= self.question_count() { + self.submit_answers(); + } else { + self.move_question(true); + } + } + + /// Build the response payload and dispatch it to the app. + fn submit_answers(&mut self) { + let mut answers = HashMap::new(); + for (idx, question) in self.request.questions.iter().enumerate() { + let answer_state = &self.answers[idx]; + let options = question.options.as_ref(); + // For option questions we always produce a selection. + let selected_idx = if options.is_some_and(|opts| !opts.is_empty()) { + answer_state + .selected + .or(answer_state.option_state.selected_idx) + } else { + answer_state.selected + }; + // Notes are appended as extra answers. When options exist, notes are per selected option. + let notes = if options.is_some_and(|opts| !opts.is_empty()) { + selected_idx + .and_then(|selected| answer_state.option_notes.get(selected)) + .map(|entry| entry.text.text().trim().to_string()) + .unwrap_or_default() + } else { + answer_state.notes.text.text().trim().to_string() + }; + let selected_label = selected_idx.and_then(|selected_idx| { + question + .options + .as_ref() + .and_then(|opts| opts.get(selected_idx)) + .map(|opt| opt.label.clone()) + }); + let mut answer_list = selected_label.into_iter().collect::>(); + if !notes.is_empty() { + answer_list.push(format!("user_note: {notes}")); + } + answers.insert( + question.id.clone(), + RequestUserInputAnswer { + answers: answer_list, + }, + ); + } + self.app_event_tx + .send(AppEvent::CodexOp(Op::UserInputAnswer { + id: self.request.turn_id.clone(), + response: RequestUserInputResponse { answers }, + })); + if let Some(next) = self.queue.pop_front() { + self.request = next; + self.reset_for_request(); + self.ensure_focus_available(); + } else { + self.done = true; + } + } + + /// Count freeform-only questions that have no notes. + fn unanswered_count(&self) -> usize { + self.request + .questions + .iter() + .enumerate() + .filter(|(idx, question)| { + let answer = &self.answers[*idx]; + let options = question.options.as_ref(); + if options.is_some_and(|opts| !opts.is_empty()) { + false + } else { + answer.notes.text.text().trim().is_empty() + } + }) + .count() + } + + /// Compute the preferred notes input height for the current question. + fn notes_input_height(&self, width: u16) -> u16 { + let Some(entry) = self.current_notes_entry() else { + return 3; + }; + let usable_width = width.saturating_sub(2); + let text_height = entry.text.desired_height(usable_width).clamp(1, 6); + text_height.saturating_add(2).clamp(3, 8) + } +} + +impl BottomPaneView for RequestUserInputOverlay { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if matches!(key_event.code, KeyCode::Esc) { + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.done = true; + return; + } + + // Question navigation is always available. + match key_event.code { + KeyCode::PageUp => { + self.move_question(false); + return; + } + KeyCode::PageDown => { + self.move_question(true); + return; + } + _ => {} + } + + match self.focus { + Focus::Options => { + let options_len = self.options_len(); + let Some(answer) = self.current_answer_mut() else { + return; + }; + // Keep selection synchronized as the user moves. + match key_event.code { + KeyCode::Up => { + answer.option_state.move_up_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + KeyCode::Down => { + answer.option_state.move_down_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + KeyCode::Char(' ') => { + self.select_current_option(); + } + KeyCode::Enter => { + self.select_current_option(); + self.go_next_or_submit(); + } + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete => { + // Any typing while in options switches to notes for fast freeform input. + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.input(key_event); + } + } + _ => {} + } + } + Focus::Notes => { + if matches!(key_event.code, KeyCode::Enter) { + self.go_next_or_submit(); + return; + } + if self.has_options() && matches!(key_event.code, KeyCode::Up | KeyCode::Down) { + let options_len = self.options_len(); + let Some(answer) = self.current_answer_mut() else { + return; + }; + match key_event.code { + KeyCode::Up => { + answer.option_state.move_up_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + KeyCode::Down => { + answer.option_state.move_down_wrap(options_len); + answer.selected = answer.option_state.selected_idx; + } + _ => {} + } + return; + } + // Notes are per option when options exist. + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.input(key_event); + } + } + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); + self.done = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.done + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if pasted.is_empty() { + return false; + } + if matches!(self.focus, Focus::Notes) { + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.insert_str(&pasted); + return true; + } + return true; + } + if matches!(self.focus, Focus::Options) { + // Treat pastes the same as typing: switch into notes. + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); + if let Some(entry) = self.current_notes_entry_mut() { + entry.text.insert_str(&pasted); + return true; + } + return true; + } + false + } + + fn try_consume_user_input_request( + &mut self, + request: RequestUserInputEvent, + ) -> Option { + self.queue.push_back(request); + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; + use codex_protocol::request_user_input::RequestUserInputQuestion; + use codex_protocol::request_user_input::RequestUserInputQuestionOption; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn test_sender() -> ( + AppEventSender, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx_raw, rx) = unbounded_channel::(); + (AppEventSender::new(tx_raw), rx) + } + + fn question_with_options(id: &str, header: &str) -> RequestUserInputQuestion { + RequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Choose an option.".to_string(), + options: Some(vec![ + RequestUserInputQuestionOption { + label: "Option 1".to_string(), + description: "First choice.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Option 2".to_string(), + description: "Second choice.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Option 3".to_string(), + description: "Third choice.".to_string(), + }, + ]), + } + } + + fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion { + RequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Share details.".to_string(), + options: None, + } + } + + fn request_event( + turn_id: &str, + questions: Vec, + ) -> RequestUserInputEvent { + RequestUserInputEvent { + call_id: "call-1".to_string(), + turn_id: turn_id.to_string(), + questions, + } + } + + fn snapshot_buffer(buf: &Buffer) -> String { + let mut lines = Vec::new(); + for y in 0..buf.area().height { + let mut row = String::new(); + for x in 0..buf.area().width { + row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + lines.push(row); + } + lines.join("\n") + } + + fn render_snapshot(overlay: &RequestUserInputOverlay, area: Rect) -> String { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + snapshot_buffer(&buf) + } + + #[test] + fn queued_requests_are_fifo() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "First")]), + tx, + ); + overlay.try_consume_user_input_request(request_event( + "turn-2", + vec![question_with_options("q2", "Second")], + )); + overlay.try_consume_user_input_request(request_event( + "turn-3", + vec![question_with_options("q3", "Third")], + )); + + overlay.submit_answers(); + assert_eq!(overlay.request.turn_id, "turn-2"); + + overlay.submit_answers(); + assert_eq!(overlay.request.turn_id, "turn-3"); + } + + #[test] + fn options_always_return_a_selection() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + ); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { id, response }) = event else { + panic!("expected UserInputAnswer"); + }; + assert_eq!(id, "turn-1"); + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, vec!["Option 1".to_string()]); + } + + #[test] + fn freeform_questions_submit_empty_when_empty() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Notes")]), + tx, + ); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!(answer.answers, Vec::::new()); + } + + #[test] + fn notes_are_captured_for_selected_option() { + let (tx, mut rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + ); + + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.option_state.selected_idx = Some(1); + } + overlay.select_current_option(); + overlay + .current_notes_entry_mut() + .expect("notes entry missing") + .text + .insert_str("Notes for option 2"); + + overlay.submit_answers(); + + let event = rx.try_recv().expect("expected AppEvent"); + let AppEvent::CodexOp(Op::UserInputAnswer { response, .. }) = event else { + panic!("expected UserInputAnswer"); + }; + let answer = response.answers.get("q1").expect("answer missing"); + assert_eq!( + answer.answers, + vec![ + "Option 2".to_string(), + "user_note: Notes for option 2".to_string(), + ] + ); + } + + #[test] + fn request_user_input_options_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + ); + let area = Rect::new(0, 0, 64, 16); + insta::assert_snapshot!( + "request_user_input_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_tight_height_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Area")]), + tx, + ); + let area = Rect::new(0, 0, 60, 8); + insta::assert_snapshot!( + "request_user_input_tight_height", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_scroll_options_snapshot() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![RequestUserInputQuestion { + id: "q1".to_string(), + header: "Next Step".to_string(), + question: "What would you like to do next?".to_string(), + options: Some(vec![ + RequestUserInputQuestionOption { + label: "Discuss a code change (Recommended)".to_string(), + description: "Walk through a plan and edit code together.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Run tests".to_string(), + description: "Pick a crate and run its tests.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Review a diff".to_string(), + description: "Summarize or review current changes.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Refactor".to_string(), + description: "Tighten structure and remove dead code.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Ship it".to_string(), + description: "Finalize and open a PR.".to_string(), + }, + ]), + }], + ), + tx, + ); + { + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.option_state.selected_idx = Some(3); + answer.selected = Some(3); + } + let area = Rect::new(0, 0, 68, 10); + insta::assert_snapshot!( + "request_user_input_scrolling_options", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn request_user_input_freeform_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + ); + let area = Rect::new(0, 0, 64, 10); + insta::assert_snapshot!( + "request_user_input_freeform", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn options_scroll_while_editing_notes() { + let (tx, _rx) = test_sender(); + let mut overlay = RequestUserInputOverlay::new( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + ); + overlay.focus = Focus::Notes; + overlay + .current_notes_entry_mut() + .expect("notes entry missing") + .text + .insert_str("Notes"); + + overlay.handle_key_event(KeyEvent::from(KeyCode::Down)); + + let answer = overlay.current_answer().expect("answer missing"); + assert_eq!(answer.selected, Some(1)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs new file mode 100644 index 0000000000..68b7eefdc3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -0,0 +1,375 @@ +use crossterm::event::KeyCode; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; + +use crate::bottom_pane::selection_popup_common::GenericDisplayRow; +use crate::bottom_pane::selection_popup_common::render_rows; +use crate::key_hint; +use crate::render::renderable::Renderable; + +use super::RequestUserInputOverlay; + +impl Renderable for RequestUserInputOverlay { + fn desired_height(&self, width: u16) -> u16 { + let sections = self.layout_sections(Rect::new(0, 0, width, u16::MAX)); + let mut height = sections + .question_lines + .len() + .saturating_add(5) + .saturating_add(self.notes_input_height(width) as usize) + .saturating_add(sections.footer_lines as usize); + if self.has_options() { + height = height.saturating_add(2); + } + height = height.max(8); + height as u16 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_ui(area, buf); + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_impl(area) + } +} + +impl RequestUserInputOverlay { + /// Render the full request-user-input overlay. + pub(super) fn render_ui(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + let sections = self.layout_sections(area); + + // Progress header keeps the user oriented across multiple questions. + let progress_line = if self.question_count() > 0 { + let idx = self.current_index() + 1; + let total = self.question_count(); + Line::from(format!("Question {idx}/{total}").dim()) + } else { + Line::from("No questions".dim()) + }; + Paragraph::new(progress_line).render(sections.progress_area, buf); + + // Question title and wrapped prompt text. + let question_header = self.current_question().map(|q| q.header.clone()); + let header_line = if let Some(header) = question_header { + Line::from(header.bold()) + } else { + Line::from("No questions".dim()) + }; + Paragraph::new(header_line).render(sections.header_area, buf); + + let question_y = sections.question_area.y; + for (offset, line) in sections.question_lines.iter().enumerate() { + if question_y.saturating_add(offset as u16) + >= sections.question_area.y + sections.question_area.height + { + break; + } + Paragraph::new(Line::from(line.clone())).render( + Rect { + x: sections.question_area.x, + y: question_y.saturating_add(offset as u16), + width: sections.question_area.width, + height: 1, + }, + buf, + ); + } + + if sections.answer_title_area.height > 0 { + let answer_label = "Answer"; + let answer_title = if self.focus_is_options() || self.focus_is_notes_without_options() { + answer_label.cyan().bold() + } else { + answer_label.dim() + }; + Paragraph::new(Line::from(answer_title)).render(sections.answer_title_area, buf); + } + + // Build rows with selection markers for the shared selection renderer. + let option_rows = self + .current_question() + .and_then(|question| question.options.as_ref()) + .map(|options| { + options + .iter() + .enumerate() + .map(|(idx, opt)| { + let selected = self + .current_answer() + .and_then(|answer| answer.selected) + .is_some_and(|sel| sel == idx); + let prefix = if selected { "(x)" } else { "( )" }; + GenericDisplayRow { + name: format!("{prefix} {}", opt.label), + description: Some(opt.description.clone()), + ..Default::default() + } + }) + .collect::>() + }) + .unwrap_or_default(); + + if self.has_options() { + let mut option_state = self + .current_answer() + .map(|answer| answer.option_state) + .unwrap_or_default(); + if sections.options_area.height > 0 { + // Ensure the selected option is visible in the scroll window. + option_state + .ensure_visible(option_rows.len(), sections.options_area.height as usize); + render_rows( + sections.options_area, + buf, + &option_rows, + &option_state, + option_rows.len().max(1), + "No options", + ); + } + } + + if sections.notes_title_area.height > 0 { + let notes_label = if self.has_options() + && self + .current_answer() + .is_some_and(|answer| answer.selected.is_some()) + { + if let Some(label) = self.current_option_label() { + format!("Notes for {label} (optional)") + } else { + "Notes (optional)".to_string() + } + } else { + "Notes (optional)".to_string() + }; + let notes_title = if self.focus_is_notes() { + notes_label.as_str().cyan().bold() + } else { + notes_label.as_str().dim() + }; + Paragraph::new(Line::from(notes_title)).render(sections.notes_title_area, buf); + } + + if sections.notes_area.height > 0 { + self.render_notes_input(sections.notes_area, buf); + } + + let footer_y = sections + .notes_area + .y + .saturating_add(sections.notes_area.height); + if sections.footer_lines == 2 { + // Status line for unanswered count when any question is empty. + let warning = format!( + "Unanswered: {} | Will submit as skipped", + self.unanswered_count() + ); + Paragraph::new(Line::from(warning.dim())).render( + Rect { + x: area.x, + y: footer_y, + width: area.width, + height: 1, + }, + buf, + ); + } + let hint_y = footer_y.saturating_add(sections.footer_lines.saturating_sub(1)); + // Footer hints (selection index + navigation keys). + let mut hint_spans = Vec::new(); + if self.has_options() { + let options_len = self.options_len(); + let option_index = self.selected_option_index().map_or(0, |idx| idx + 1); + hint_spans.extend(vec![ + format!("Option {option_index} of {options_len}").into(), + " | ".into(), + ]); + } + hint_spans.extend(vec![ + key_hint::plain(KeyCode::Up).into(), + "/".into(), + key_hint::plain(KeyCode::Down).into(), + " scroll | ".into(), + key_hint::plain(KeyCode::Enter).into(), + " next question | ".into(), + ]); + if self.question_count() > 1 { + hint_spans.extend(vec![ + key_hint::plain(KeyCode::PageUp).into(), + " prev | ".into(), + key_hint::plain(KeyCode::PageDown).into(), + " next | ".into(), + ]); + } + hint_spans.extend(vec![ + key_hint::plain(KeyCode::Esc).into(), + " interrupt".into(), + ]); + Paragraph::new(Line::from(hint_spans).dim()).render( + Rect { + x: area.x, + y: hint_y, + width: area.width, + height: 1, + }, + buf, + ); + } + + /// Return the cursor position when editing notes, if visible. + pub(super) fn cursor_pos_impl(&self, area: Rect) -> Option<(u16, u16)> { + if !self.focus_is_notes() { + return None; + } + let sections = self.layout_sections(area); + let entry = self.current_notes_entry()?; + let input_area = sections.notes_area; + if input_area.width <= 2 || input_area.height == 0 { + return None; + } + if input_area.height < 3 { + // Inline notes layout uses a prefix and a single-line text area. + let prefix = notes_prefix(); + let prefix_width = prefix.len() as u16; + if input_area.width <= prefix_width { + return None; + } + let textarea_rect = Rect { + x: input_area.x.saturating_add(prefix_width), + y: input_area.y, + width: input_area.width.saturating_sub(prefix_width), + height: 1, + }; + let state = *entry.state.borrow(); + return entry.text.cursor_pos_with_state(textarea_rect, state); + } + let text_area_height = input_area.height.saturating_sub(2); + let textarea_rect = Rect { + x: input_area.x.saturating_add(1), + y: input_area.y.saturating_add(1), + width: input_area.width.saturating_sub(2), + height: text_area_height, + }; + let state = *entry.state.borrow(); + entry.text.cursor_pos_with_state(textarea_rect, state) + } + + /// Render the notes input box or inline notes field. + fn render_notes_input(&self, area: Rect, buf: &mut Buffer) { + let Some(entry) = self.current_notes_entry() else { + return; + }; + if area.width < 2 || area.height == 0 { + return; + } + if area.height < 3 { + // Inline notes field for tight layouts. + let prefix = notes_prefix(); + let prefix_width = prefix.len() as u16; + if area.width <= prefix_width { + Paragraph::new(Line::from(prefix.dim())).render(area, buf); + return; + } + Paragraph::new(Line::from(prefix.dim())).render( + Rect { + x: area.x, + y: area.y, + width: prefix_width, + height: 1, + }, + buf, + ); + let textarea_rect = Rect { + x: area.x.saturating_add(prefix_width), + y: area.y, + width: area.width.saturating_sub(prefix_width), + height: 1, + }; + let mut state = entry.state.borrow_mut(); + Clear.render(textarea_rect, buf); + StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); + if entry.text.text().is_empty() { + Paragraph::new(Line::from(self.notes_placeholder().dim())) + .render(textarea_rect, buf); + } + return; + } + // Draw a light ASCII frame around the notes area. + let top_border = format!("+{}+", "-".repeat(area.width.saturating_sub(2) as usize)); + let bottom_border = top_border.clone(); + Paragraph::new(Line::from(top_border)).render( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }, + buf, + ); + Paragraph::new(Line::from(bottom_border)).render( + Rect { + x: area.x, + y: area.y.saturating_add(area.height.saturating_sub(1)), + width: area.width, + height: 1, + }, + buf, + ); + for row in 1..area.height.saturating_sub(1) { + Line::from(vec![ + "|".into(), + " ".repeat(area.width.saturating_sub(2) as usize).into(), + "|".into(), + ]) + .render( + Rect { + x: area.x, + y: area.y.saturating_add(row), + width: area.width, + height: 1, + }, + buf, + ); + } + let text_area_height = area.height.saturating_sub(2); + let textarea_rect = Rect { + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2), + height: text_area_height, + }; + let mut state = entry.state.borrow_mut(); + Clear.render(textarea_rect, buf); + StatefulWidgetRef::render_ref(&(&entry.text), textarea_rect, buf, &mut state); + if entry.text.text().is_empty() { + Paragraph::new(Line::from(self.notes_placeholder().dim())).render(textarea_rect, buf); + } + } + + fn focus_is_options(&self) -> bool { + matches!(self.focus, super::Focus::Options) + } + + fn focus_is_notes(&self) -> bool { + matches!(self.focus, super::Focus::Notes) + } + + fn focus_is_notes_without_options(&self) -> bool { + !self.has_options() && self.focus_is_notes() + } +} + +fn notes_prefix() -> &'static str { + "Notes: " +} diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap new file mode 100644 index 0000000000..b698776b13 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Goal +Share details. ++--------------------------------------------------------------+ +|Type your answer (optional) | ++--------------------------------------------------------------+ +Unanswered: 1 | Will submit as skipped +↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap new file mode 100644 index 0000000000..bc3d7d55b2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Area +Choose an option. +Answer +(x) Option 1 First choice. +( ) Option 2 Second choice. +( ) Option 3 Third choice. + + + + +Notes for Option 1 (optional) ++--------------------------------------------------------------+ +|Add notes (optional) | ++--------------------------------------------------------------+ +Option 1 of 3 | ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap new file mode 100644 index 0000000000..22cb487413 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Next Step +What would you like to do next? +( ) Discuss a code change (Recommended) Walk through a plan and + edit code together. +( ) Run tests Pick a crate and run its + tests. +( ) Review a diff Summarize or review current +Notes: Add notes (optional) +Option 4 of 5 | ↑/↓ scroll | enter next question | esc interrupt diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap new file mode 100644 index 0000000000..e1d01df7af --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- +Question 1/1 +Area +Choose an option. +(x) Option 1 First choice. +( ) Option 2 Second choice. +( ) Option 3 Third choice. +Notes: Add notes (optional) +Option 1 of 3 | ↑/↓ scroll | enter next question | esc inter diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap index 661e82e3ad..2d5b29038a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -5,5 +5,5 @@ expression: terminal.backend() " " "› /mo " " " -" /model choose what model and reasoning effort to use " -" /mention mention a file " +" " +" /model choose what model and reasoning effort to use " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap index 123a5eb3a3..5aea415190 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -5,7 +5,6 @@ expression: "render_snapshot(&pane, area)" ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap index 27df671e4d..e651ec9274 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -3,10 +3,10 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) + ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap similarity index 75% rename from codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap index 123a5eb3a3..79e1e126eb 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_only_snapshot.snap @@ -2,8 +2,7 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- - ↳ Queued follow-up question - ⌥ + ↑ edit +• Working (0s • esc to interrupt) › Ask Codex to do anything diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap similarity index 82% rename from codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap rename to codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap index 27df671e4d..12090d09e9 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_with_details_and_queued_messages_snapshot.snap @@ -3,10 +3,12 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- • Working (0s • esc to interrupt) + └ First detail line + Second detail line + ↳ Queued follow-up question ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 903ebe9f82..3a849822b1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1,4 +1,6 @@ use crate::key_hint::is_altgr; +use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -60,10 +62,36 @@ impl TextArea { } } - pub fn set_text(&mut self, text: &str) { + /// Replace the textarea text and clear any existing text elements. + pub fn set_text_clearing_elements(&mut self, text: &str) { + self.set_text_inner(text, None); + } + + /// Replace the textarea text and set the provided text elements. + pub fn set_text_with_elements(&mut self, text: &str, elements: &[UserTextElement]) { + self.set_text_inner(text, Some(elements)); + } + + fn set_text_inner(&mut self, text: &str, elements: Option<&[UserTextElement]>) { + // Stage 1: replace the raw text and keep the cursor in a safe byte range. self.text = text.to_string(); self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); + // Stage 2: rebuild element ranges from scratch against the new text. self.elements.clear(); + if let Some(elements) = elements { + for elem in elements { + let mut start = elem.byte_range.start.min(self.text.len()); + let mut end = elem.byte_range.end.min(self.text.len()); + start = self.clamp_pos_to_char_boundary(start); + end = self.clamp_pos_to_char_boundary(end); + if start >= end { + continue; + } + self.elements.push(TextElement { range: start..end }); + } + self.elements.sort_by_key(|e| e.range.start); + } + // Stage 3: clamp the cursor and reset derived state tied to the prior content. self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); self.wrap_cache.replace(None); self.preferred_col = None; @@ -722,6 +750,22 @@ impl TextArea { .collect() } + pub fn text_elements(&self) -> Vec { + self.elements + .iter() + .map(|e| { + let placeholder = self.text.get(e.range.clone()).map(str::to_string); + UserTextElement::new( + ByteRange { + start: e.range.start, + end: e.range.end, + }, + placeholder, + ) + }) + .collect() + } + pub fn element_payload_starting_at(&self, pos: usize) -> Option { let pos = pos.min(self.text.len()); let elem = self.elements.iter().find(|e| e.range.start == pos)?; @@ -1251,7 +1295,7 @@ mod tests { let mut t = TextArea::new(); t.insert_str("abcd"); t.set_cursor(1); - t.set_text("你"); + t.set_text_clearing_elements("你"); assert_eq!(t.cursor(), 0); t.insert_str("a"); assert_eq!(t.text(), "a你"); @@ -1933,7 +1977,7 @@ mod tests { for _ in 0..base_len { base.push_str(&rand_grapheme(&mut rng)); } - ta.set_text(&base); + ta.set_text_clearing_elements(&base); // Choose a valid char boundary for initial cursor let mut boundaries: Vec = vec![0]; boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b38feb631e..66264b17c0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -90,10 +90,16 @@ use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Settings; +use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::request_user_input::RequestUserInputEvent; +use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -130,6 +136,7 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::InputResult; +use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -348,14 +355,14 @@ pub(crate) struct ChatWidgetInit { pub(crate) config: Config, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_prompt: Option, - pub(crate) initial_images: Vec, + pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) model: Option, + pub(crate) otel_manager: OtelManager, } #[derive(Default)] @@ -383,8 +390,6 @@ pub(crate) enum ExternalEditorState { Active, } -type CollaborationModeSelection = collaboration_modes::Selection; - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -413,14 +418,15 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - model: Option, - /// Current UI selection for collaboration modes. + /// Stored collaboration mode with model and reasoning effort. /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, + /// When collaboration modes feature is enabled, this is initialized to the first preset. + /// When disabled, this is Custom. The model and reasoning effort are stored here instead of + /// being read from config or current_model. + stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, + otel_manager: OtelManager, session_header: SessionHeader, initial_user_message: Option, token_info: Option, @@ -494,6 +500,11 @@ pub(crate) struct ChatWidget { // This gates rendering of the "Worked for …" separator so purely conversational turns don't // show an empty divider. It is reset when the separator is emitted. had_work_activity: bool, + // Status-indicator elapsed seconds captured at the last emitted final-message separator. + // + // This lets the separator show per-chunk work time (since the previous separator) rather than + // the total task-running time reported by the status indicator. + last_separator_elapsed_secs: Option, last_rendered_width: std::cell::Cell>, // Feedback sink for /feedback @@ -527,16 +538,19 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) animation_tick: Option, } -struct UserMessage { +pub(crate) struct UserMessage { text: String, - image_paths: Vec, + local_images: Vec, + text_elements: Vec, } impl From for UserMessage { fn from(text: String) -> Self { Self { text, - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } @@ -545,16 +559,107 @@ impl From<&str> for UserMessage { fn from(text: &str) -> Self { Self { text: text.to_string(), - image_paths: Vec::new(), + local_images: Vec::new(), + // Plain text conversion has no UI element ranges. + text_elements: Vec::new(), } } } -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { +pub(crate) fn create_initial_user_message( + text: Option, + local_image_paths: Vec, + text_elements: Vec, +) -> Option { + let text = text.unwrap_or_default(); + if text.is_empty() && local_image_paths.is_empty() { None } else { - Some(UserMessage { text, image_paths }) + let local_images = local_image_paths + .into_iter() + .enumerate() + .map(|(idx, path)| LocalImageAttachment { + placeholder: local_image_label_text(idx + 1), + path, + }) + .collect(); + Some(UserMessage { + text, + local_images, + text_elements, + }) + } +} + +// When merging multiple queued drafts (e.g., after interrupt), each draft starts numbering +// its attachments at [Image #1]. Reassign placeholder labels based on the attachment list so +// the combined local_image_paths order matches the labels, even if placeholders were moved +// in the text (e.g., [Image #2] appearing before [Image #1]). +fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) -> UserMessage { + let UserMessage { + text, + text_elements, + local_images, + } = message; + if local_images.is_empty() { + return UserMessage { + text, + text_elements, + local_images, + }; + } + + let mut mapping: HashMap = HashMap::new(); + let mut remapped_images = Vec::new(); + for attachment in local_images { + let new_placeholder = local_image_label_text(*next_label); + *next_label += 1; + mapping.insert(attachment.placeholder.clone(), new_placeholder.clone()); + remapped_images.push(LocalImageAttachment { + placeholder: new_placeholder, + path: attachment.path, + }); + } + + let mut elements = text_elements; + elements.sort_by_key(|elem| elem.byte_range.start); + + let mut cursor = 0usize; + let mut rebuilt = String::new(); + let mut rebuilt_elements = Vec::new(); + for mut elem in elements { + let start = elem.byte_range.start.min(text.len()); + let end = elem.byte_range.end.min(text.len()); + if let Some(segment) = text.get(cursor..start) { + rebuilt.push_str(segment); + } + + let original = text.get(start..end).unwrap_or(""); + let placeholder = elem.placeholder(&text); + let replacement = placeholder + .and_then(|ph| mapping.get(ph)) + .map(String::as_str) + .unwrap_or(original); + + let elem_start = rebuilt.len(); + rebuilt.push_str(replacement); + let elem_end = rebuilt.len(); + + if let Some(remapped) = placeholder.and_then(|ph| mapping.get(ph)) { + elem.set_placeholder(Some(remapped.clone())); + } + elem.byte_range = (elem_start..elem_end).into(); + rebuilt_elements.push(elem); + cursor = end; + } + if let Some(segment) = text.get(cursor..) { + rebuilt.push_str(segment); + } + + UserMessage { + text: rebuilt, + local_images: remapped_images, + text_elements: rebuilt_elements, } } @@ -626,13 +731,24 @@ impl ChatWidget { self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); self.session_header.set_model(&model_for_header); + // Only update stored collaboration settings when collaboration modes are disabled. + // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its + // instructions as-is; the session configured event should not override it. + if !self.collaboration_modes_enabled() { + self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + } let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, event, self.show_welcome_banner, + self.collaboration_modes_enabled(), + self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -883,7 +999,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) + && self.current_model() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -918,6 +1034,7 @@ impl ChatWidget { self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; + self.unified_exec_wait_streak = None; self.clear_unified_exec_processes(); self.stream_controller = None; self.maybe_show_pending_rate_limit_prompt(); @@ -1018,31 +1135,76 @@ impl ChatWidget { )); } - // If any messages were queued during the task, restore them into the composer. - if !self.queued_user_messages.is_empty() { - let queued_text = self - .queued_user_messages + if let Some(combined) = self.drain_queued_messages_for_restore() { + let combined_local_image_paths = combined + .local_images .iter() - .map(|m| m.text.clone()) - .collect::>() - .join("\n"); - let existing_text = self.bottom_pane.composer_text(); - let combined = if existing_text.is_empty() { - queued_text - } else if queued_text.is_empty() { - existing_text - } else { - format!("{queued_text}\n{existing_text}") - }; - self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. - self.queued_user_messages.clear(); + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + combined.text, + combined.text_elements, + combined_local_image_paths, + ); self.refresh_queued_user_messages(); } self.request_redraw(); } + /// Merge queued drafts (plus the current composer state) into a single message for restore. + /// + /// Each queued draft numbers attachments from `[Image #1]`. When we concatenate drafts, we + /// must renumber placeholders in a stable order so the merged attachment list stays aligned + /// with the labels embedded in text. This helper drains the queue, remaps placeholders, and + /// fixes text element byte ranges as content is appended. Returns `None` when there is nothing + /// to restore. + fn drain_queued_messages_for_restore(&mut self) -> Option { + if self.queued_user_messages.is_empty() { + return None; + } + + let existing_message = UserMessage { + text: self.bottom_pane.composer_text(), + text_elements: self.bottom_pane.composer_text_elements(), + local_images: self.bottom_pane.composer_local_images(), + }; + + let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); + if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { + to_merge.push(existing_message); + } + + let mut combined = UserMessage { + text: String::new(), + text_elements: Vec::new(), + local_images: Vec::new(), + }; + let mut combined_offset = 0usize; + let mut next_image_label = 1usize; + + for (idx, message) in to_merge.into_iter().enumerate() { + if idx > 0 { + combined.text.push('\n'); + combined_offset += 1; + } + let message = remap_placeholders_for_message(message, &mut next_image_label); + let base = combined_offset; + combined.text.push_str(&message.text); + combined_offset += message.text.len(); + combined + .text_elements + .extend(message.text_elements.into_iter().map(|mut elem| { + elem.byte_range.start += base; + elem.byte_range.end += base; + elem + })); + combined.local_images.extend(message.local_images); + } + + Some(combined) + } + fn on_plan_update(&mut self, update: UpdatePlanArgs) { self.add_to_history(history_cell::new_plan_update(update)); } @@ -1073,6 +1235,14 @@ impl ChatWidget { ); } + fn on_request_user_input(&mut self, ev: RequestUserInputEvent) { + let ev2 = ev.clone(); + self.defer_or_handle( + |q| q.push_user_input(ev), + |s| s.handle_request_user_input_now(ev2), + ); + } + fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { @@ -1101,6 +1271,9 @@ impl ChatWidget { } fn on_terminal_interaction(&mut self, ev: TerminalInteractionEvent) { + if !self.bottom_pane.is_task_running() { + return; + } self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes @@ -1380,6 +1553,7 @@ impl ChatWidget { #[inline] fn handle_streaming_delta(&mut self, delta: String) { // Before streaming agent content, flush any active exec cell group. + self.flush_unified_exec_wait_streak(); self.flush_active_cell(); if self.stream_controller.is_none() { @@ -1389,7 +1563,8 @@ impl ChatWidget { let elapsed_seconds = self .bottom_pane .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) + .map(|current| self.worked_elapsed_from(current)); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; self.had_work_activity = false; @@ -1409,6 +1584,17 @@ impl ChatWidget { self.request_redraw(); } + fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 { + let baseline = match self.last_separator_elapsed_secs { + Some(last) if current_elapsed < last => 0, + Some(last) => last, + None => 0, + }; + let elapsed = current_elapsed.saturating_sub(baseline); + self.last_separator_elapsed_secs = Some(current_elapsed); + elapsed + } + pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { let running = self.running_commands.remove(&ev.call_id); if self.suppressed_exec_calls.remove(&ev.call_id) { @@ -1537,6 +1723,12 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn handle_request_user_input_now(&mut self, ev: RequestUserInputEvent) { + self.flush_answer_stream_with_separator(); + self.bottom_pane.push_user_input_request(ev); + self.request_redraw(); + } + pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. self.running_commands.insert( @@ -1653,32 +1845,45 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, feedback, is_first_run, model, + otel_manager, } = common; - let mut config = config; let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; config.model = model.clone(); let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = config - .model - .clone() - .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); - let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell(&config)) + let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) } else { - None + CollaborationMode::Custom(Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }) }; + let active_cell = Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )); + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1696,15 +1901,12 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, - model, - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(model_for_header), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1738,6 +1940,7 @@ impl ChatWidget { pre_review_token_info: None, needs_final_message_separator: false, had_work_activity: false, + last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -1768,13 +1971,13 @@ impl ChatWidget { config, frame_requester, app_event_tx, - initial_prompt, - initial_images, + initial_user_message, enhanced_keys_supported, auth_manager, models_manager, feedback, model, + otel_manager, .. } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -1786,6 +1989,22 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: header_model.clone(), + reasoning_effort: None, + developer_instructions: None, + }) + }; + let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -1803,15 +2022,12 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), + stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(header_model), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), + initial_user_message, token_info: None, rate_limit_snapshot: None, plan_type: None, @@ -1845,6 +2061,7 @@ impl ChatWidget { pre_review_token_info: None, needs_final_message_separator: false, had_work_activity: false, + last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, @@ -1943,46 +2160,64 @@ impl ChatWidget { } if !self.queued_user_messages.is_empty() => { // Prefer the most recently queued item. if let Some(user_message) = self.queued_user_messages.pop_back() { - self.bottom_pane.set_composer_text(user_message.text); + let local_image_paths = user_message + .local_images + .iter() + .map(|img| img.path.clone()) + .collect(); + self.bottom_pane.set_composer_text( + user_message.text, + user_message.text_elements, + local_image_paths, + ); self.refresh_queued_user_messages(); self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // Enter always sends messages immediately (bypasses queue check) - // Clear any reasoning status header when submitting a new message + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + if self.is_session_configured() { + // Submitted is only emitted when steer is enabled (Enter sends immediately). + // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); self.full_reasoning_buffer.clear(); self.set_status_header(String::from("Working")); - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - if !self.is_session_configured() { - self.queue_user_message(user_message); - } else { - self.submit_user_message(user_message); - } - } - InputResult::Queued(text) => { - // Tab queues the message if a task is running, otherwise submits immediately - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; + self.submit_user_message(user_message); + } else { self.queue_user_message(user_message); } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} } - } + InputResult::Queued { + text, + text_elements, + } => { + let user_message = UserMessage { + text, + local_images: self + .bottom_pane + .take_recent_submission_images_with_placeholders(), + text_elements, + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } @@ -2074,7 +2309,7 @@ impl ChatWidget { } SlashCommand::Collab => { if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); + self.open_collaboration_modes_popup(); } } SlashCommand::Approvals => { @@ -2111,11 +2346,17 @@ impl ChatWidget { return; } + self.otel_manager.counter( + "codex.windows_sandbox.setup_elevated_sandbox_command", + 1, + &[], + ); self.app_event_tx .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); } #[cfg(not(target_os = "windows"))] { + let _ = &self.otel_manager; // Not supported; on non-Windows this command should never be reachable. }; } @@ -2236,14 +2477,10 @@ impl ChatWidget { let trimmed = args.trim(); match cmd { - SlashCommand::Collab if !trimmed.is_empty() => { - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - self.request_redraw(); + SlashCommand::Collab => { + let _ = trimmed; + if self.collaboration_modes_enabled() { + self.open_collaboration_modes_popup(); } } SlashCommand::Review if !trimmed.is_empty() => { @@ -2323,16 +2560,19 @@ impl ChatWidget { } fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); + if !self.is_session_configured() { + tracing::warn!("cannot submit user message before session is configured; queueing"); self.queued_user_messages.push_front(user_message); self.refresh_queued_user_messages(); return; - }; - let model = model.to_string(); + } - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { + let UserMessage { + text, + local_images, + text_elements, + } = user_message; + if text.is_empty() && local_images.is_empty() { return; } @@ -2356,15 +2596,16 @@ impl ChatWidget { return; } - for path in image_paths { - items.push(UserInput::LocalImage { path }); + for image in &local_images { + items.push(UserInput::LocalImage { + path: image.path.clone(), + }); } if !text.is_empty() { - // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. items.push(UserInput::Text { text: text.clone(), - text_elements: Vec::new(), + text_elements: text_elements.clone(), }); } @@ -2378,24 +2619,18 @@ impl ChatWidget { } } - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, + model: self.stored_collaboration_mode.model().to_string(), + effort: self.stored_collaboration_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode, + collaboration_mode: self + .collaboration_modes_enabled() + .then(|| self.stored_collaboration_mode.clone()), }; self.codex_op_tx.send(op).unwrap_or_else(|e| { @@ -2413,7 +2648,12 @@ impl ChatWidget { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text)); + let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.add_to_history(history_cell::new_user_prompt( + text, + text_elements, + local_image_paths, + )); } self.needs_final_message_separator = false; @@ -2510,6 +2750,9 @@ impl ChatWidget { EventMsg::ElicitationRequest(ev) => { self.on_elicitation_request(ev); } + EventMsg::RequestUserInput(ev) => { + self.on_request_user_input(ev); + } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), @@ -2570,8 +2813,7 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::RequestUserInput(_) => {} + | EventMsg::ReasoningRawContentDelta(_) => {} } } @@ -2629,10 +2871,16 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { - let message = event.message.trim(); - if !message.is_empty() { - self.add_to_history(history_cell::new_user_prompt(message.to_string())); + if !event.message.trim().is_empty() { + self.add_to_history(history_cell::new_user_prompt( + event.message, + event.text_elements, + event.local_images, + )); } + + // User messages reset separator state so the next agent response doesn't add a stray break. + self.needs_final_message_separator = false; } /// Exit the UI immediately without waiting for shutdown. @@ -2725,11 +2973,8 @@ impl ChatWidget { let total_usage = token_info .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); - let collaboration_mode = if self.collaboration_modes_enabled() { - Some(self.collaboration_mode.label()) - } else { - None - }; + let collaboration_mode = self.collaboration_mode_label(); + let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -2742,6 +2987,7 @@ impl ChatWidget { Local::now(), self.model_display_name(), collaboration_mode, + reasoning_effort_override, )); } @@ -2987,7 +3233,7 @@ impl ChatWidget { let current_model = self.current_model(); let current_label = presets .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) + .find(|preset| preset.model.as_str() == current_model) .map(|preset| preset.display_name.to_string()) .unwrap_or_else(|| self.model_display_name().to_string()); @@ -3015,7 +3261,7 @@ impl ChatWidget { SelectionItem { name: preset.display_name.clone(), description, - is_current: Some(model.as_str()) == current_model, + is_current: model.as_str() == current_model, is_default: preset.is_default, actions, dismiss_on_select: true, @@ -3085,7 +3331,7 @@ impl ChatWidget { for preset in presets.into_iter() { let description = (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); + let is_current = preset.model.as_str() == self.current_model(); let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); let actions: Vec = vec![Box::new(move |tx| { @@ -3117,6 +3363,49 @@ impl ChatWidget { }); } + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = self.models_manager.list_collaboration_modes(); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + None, + ); + return; + } + + let items: Vec = presets + .into_iter() + .map(|preset| { + let name = match preset { + CollaborationMode::Plan(_) => "Plan", + CollaborationMode::PairProgramming(_) => "Pair Programming", + CollaborationMode::Execute(_) => "Execute", + CollaborationMode::Custom(_) => "Custom", + }; + let is_current = + collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + })]; + SelectionItem { + name: name.to_string(), + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn model_selection_actions( model_for_action: String, effort_for_action: Option, @@ -3212,9 +3501,9 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); + let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.config.model_reasoning_effort + self.stored_collaboration_mode.reasoning_effort() } else { default_choice }; @@ -3767,22 +4056,8 @@ impl ChatWidget { .is_some_and(|preset| { Self::preset_matches_current(current_approval, current_sandbox, preset) }); - let stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; + self.otel_manager + .counter("codex.windows_sandbox.elevated_prompt_shown", 1, &[]); let mut header = ColumnRenderable::new(); header.push(*Box::new( @@ -3795,11 +4070,39 @@ impl ChatWidget { .wrap(Wrap { trim: false }), )); + let stay_label = if stay_full_access { + "Stay in Agent Full Access".to_string() + } else { + "Stay in Read-Only".to_string() + }; + let mut stay_actions = if stay_full_access { + Vec::new() + } else { + presets + .iter() + .find(|preset| preset.id == "read-only") + .map(|preset| { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }) + .unwrap_or_default() + }; + stay_actions.insert( + 0, + Box::new({ + let otel = self.otel_manager.clone(); + move |_tx| { + otel.counter("codex.windows_sandbox.elevated_prompt_decline", 1, &[]); + } + }), + ); + + let accept_otel = self.otel_manager.clone(); let items = vec![ SelectionItem { name: "Set up agent sandbox (requires elevation)".to_string(), description: None, actions: vec![Box::new(move |tx| { + accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), }); @@ -3847,23 +4150,6 @@ impl ChatWidget { .is_some_and(|preset| { Self::preset_matches_current(current_approval, current_sandbox, preset) }); - let stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - let mut lines = Vec::new(); lines.push(line!["Use Non-Elevated Sandbox?".bold()]); lines.push(line![""]); @@ -3879,14 +4165,44 @@ impl ChatWidget { let elevated_preset = preset.clone(); let legacy_preset = preset; + let stay_label = if stay_full_access { + "Stay in Agent Full Access".to_string() + } else { + "Stay in Read-Only".to_string() + }; + let mut stay_actions = if stay_full_access { + Vec::new() + } else { + presets + .iter() + .find(|preset| preset.id == "read-only") + .map(|preset| { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }) + .unwrap_or_default() + }; + stay_actions.insert( + 0, + Box::new({ + let otel = self.otel_manager.clone(); + move |_tx| { + otel.counter("codex.windows_sandbox.fallback_stay_current", 1, &[]); + } + }), + ); let items = vec![ SelectionItem { name: "Try elevated agent sandbox setup again".to_string(), description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: elevated_preset.clone(), - }); + actions: vec![Box::new({ + let otel = self.otel_manager.clone(); + let preset = elevated_preset; + move |tx| { + otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + }); + } })], dismiss_on_select: true, ..Default::default() @@ -3894,11 +4210,16 @@ impl ChatWidget { SelectionItem { name: "Use non-elevated agent sandbox".to_string(), description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: legacy_preset.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); + actions: vec![Box::new({ + let otel = self.otel_manager.clone(); + let preset = legacy_preset; + move |tx| { + otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]); + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Legacy, + }); + } })], dismiss_on_select: true, ..Default::default() @@ -4013,9 +4334,24 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); - } else if feature == Feature::CollaborationModes { + } + if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); - } else if feature == Feature::Connectors { + let settings = match &self.stored_collaboration_mode { + CollaborationMode::Plan(settings) + | CollaborationMode::PairProgramming(settings) + | CollaborationMode::Execute(settings) + | CollaborationMode::Custom(settings) => settings.clone(), + }; + self.stored_collaboration_mode = if enabled { + collaboration_modes::default_mode(self.models_manager.as_ref()) + .unwrap_or(CollaborationMode::Custom(settings)) + } else { + CollaborationMode::Custom(settings) + }; + } + + if feature == Feature::Connectors { self.bottom_pane.set_connectors_enabled(enabled); if enabled { self.prefetch_connectors(); @@ -4052,60 +4388,114 @@ impl ChatWidget { .unwrap_or(false) } - /// Set the reasoning effort in the widget's config copy. + /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(None, Some(effort), None); } - /// Set the model in the widget's config copy. + /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); - self.model = Some(model.to_string()); + self.stored_collaboration_mode = + self.stored_collaboration_mode + .with_updates(Some(model.to_string()), None, None); } - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); + pub(crate) fn current_model(&self) -> &str { + self.stored_collaboration_mode.model() } - /// Update the selected collaboration mode. - /// - /// When collaboration modes are enabled, the current selection is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { - if !self.collaboration_modes_enabled() { - return; - } - const FLASH_DURATION: Duration = Duration::from_secs(1); - - self.collaboration_mode = selection; - - let flash = collaboration_modes::flash_line(selection); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - self.request_redraw(); + #[allow(dead_code)] // Used in tests + pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { + &self.stored_collaboration_mode } - fn current_model(&self) -> Option<&str> { - self.model.as_deref() + #[cfg(test)] + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.stored_collaboration_mode.reasoning_effort() + } + + fn is_session_configured(&self) -> bool { + self.thread_id.is_some() } fn collaboration_modes_enabled(&self) -> bool { self.config.features.enabled(Feature::CollaborationModes) } + fn model_display_name(&self) -> &str { + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + match &self.stored_collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, + } + } + + /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mode) = collaboration_modes::next_mode( + self.models_manager.as_ref(), + &self.stored_collaboration_mode, + ) { + self.set_collaboration_mode(next_mode); + } + } + + /// Update the stored collaboration mode. + /// + /// When collaboration modes are enabled, the current mode is attached to *every* + /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + if !self.collaboration_modes_enabled() { + return; + } + + self.stored_collaboration_mode = mode; + + let label = self.collaboration_mode_label(); + if let Some(label) = label { + let flash = Line::from(vec![ + label.bold(), + " (".dim(), + key_hint::shift(KeyCode::Tab).into(), + " to change mode)".dim(), + ]); + const FLASH_DURATION: Duration = Duration::from_secs(2); + self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); + } + self.request_redraw(); + } + fn connectors_enabled(&self) -> bool { self.config.features.enabled(Feature::Connectors) } - fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) - } - /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell(config: &Config) -> Box { + fn placeholder_session_header_cell( + config: &Config, + is_collaboration: bool, + collaboration_mode: CollaborationMode, + ) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -4113,6 +4503,8 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, )) } @@ -4408,8 +4800,14 @@ impl ChatWidget { } /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.bottom_pane.set_composer_text(text); + pub(crate) fn set_composer_text( + &mut self, + text: String, + text_elements: Vec, + local_image_paths: Vec, + ) { + self.bottom_pane + .set_composer_text(text, text_elements, local_image_paths); } pub(crate) fn show_esc_backtrack_hint(&mut self) { @@ -4635,10 +5033,6 @@ impl ChatWidget { self.thread_id } - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - pub(crate) fn rollout_path(&self) -> Option { self.current_rollout_path.clone() } diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index dc1e683ea5..5241c79b89 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -8,6 +8,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_protocol::approvals::ElicitationRequestEvent; +use codex_protocol::request_user_input::RequestUserInputEvent; use super::ChatWidget; @@ -16,6 +17,7 @@ pub(crate) enum QueuedInterrupt { ExecApproval(String, ExecApprovalRequestEvent), ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), Elicitation(ElicitationRequestEvent), + RequestUserInput(RequestUserInputEvent), ExecBegin(ExecCommandBeginEvent), ExecEnd(ExecCommandEndEvent), McpBegin(McpToolCallBeginEvent), @@ -57,6 +59,10 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::Elicitation(ev)); } + pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) { + self.queue.push_back(QueuedInterrupt::RequestUserInput(ev)); + } + pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); } @@ -85,6 +91,7 @@ impl InterruptManager { chat.handle_apply_patch_approval_now(id, ev) } QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), + QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev), QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap index ab889de718..ff6bbec554 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1980 +assertion_line: 2654 expression: popup --- Select Approval Mode diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap index 6d9aa515b1..64361e90f9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -2,7 +2,9 @@ source: tui/src/chatwidget/tests.rs expression: term.backend().vt100().screen().contents() --- + • Working (0s • esc to interrupt) + ↳ Hello, world! 0 ↳ Hello, world! 1 ↳ Hello, world! 2 @@ -21,7 +23,6 @@ expression: term.backend().vt100().screen().contents() ↳ Hello, world! 15 ↳ Hello, world! 16 - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_clears_unified_exec_wait_streak.snap similarity index 85% rename from codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap rename to codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_clears_unified_exec_wait_streak.snap index 60715e581e..bf70c40460 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_clears_unified_exec_wait_streak.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -expression: last +expression: snapshot --- +cells=1 ■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 905925709e..d2676235a2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -6,11 +6,11 @@ expression: popup Access legacy models by running codex -m or in your config.toml › 1. gpt-5.2-codex (default) Latest frontier agentic coding model. - 2. gpt-5.1-codex-max Codex-optimized flagship for deep and fast - reasoning. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.2 Latest frontier model with improvements across + 2. gpt-5.2 Latest frontier model with improvements across knowledge, reasoning and coding + 3. gpt-5.1-codex-max Codex-optimized flagship for deep and fast + reasoning. + 4. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less + capable. Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap index 2a7717df7c..1c02350a6d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__review_queues_user_messages_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 3840 expression: term.backend().vt100().screen().contents() --- @@ -14,10 +13,10 @@ expression: term.backend().vt100().screen().contents() • Working (0s • esc to interrupt) + ↳ Queued while /review is running. ⌥ + ↑ edit - › Ask Codex to do anything 100% context left · ? for shortcuts diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap new file mode 100644 index 0000000000..5caf42e1bc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_after_final_agent_message.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +↳ Interacted with background terminal · cargo test -p codex-core + └ (waited) + +• Final response. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap new file mode 100644 index 0000000000..cfea4f8a66 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_before_streamed_agent_message.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +↳ Interacted with background terminal · cargo test -p codex-core + └ (waited) + +• Streaming response. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a226d20db8..ae158152c8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::LocalImageAttachment; +use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -46,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SessionSource; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenCountEvent; @@ -57,6 +60,7 @@ use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; @@ -67,6 +71,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::user_input::TextElement; +use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -182,6 +188,359 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn replayed_user_message_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let placeholder = "[Image #1]"; + let message = format!("{placeholder} replayed"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/replay.png")]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: message.clone(), + images: None, + text_elements: text_elements.clone(), + local_images: local_images.clone(), + })]), + rollout_path: rollout_file.path().to_path_buf(), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected a replayed user history cell"); + assert_eq!(stored_message, message); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn submission_preserves_text_elements_and_local_images() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: rollout_file.path().to_path_buf(), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let placeholder = "[Image #1]"; + let text = format!("{placeholder} submit"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/submitted.png")]; + + chat.bottom_pane + .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 2); + assert_eq!( + items[0], + UserInput::LocalImage { + path: local_images[0].clone() + } + ); + assert_eq!( + items[1], + UserInput::Text { + text: text.clone(), + text_elements: text_elements.clone(), + } + ); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, text); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); +} + +#[tokio::test] +async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + let first_placeholder = "[Image #1]"; + let first_text = format!("{first_placeholder} first"); + let first_elements = vec![TextElement::new( + (0..first_placeholder.len()).into(), + Some(first_placeholder.to_string()), + )]; + let first_images = [PathBuf::from("/tmp/first.png")]; + + let second_placeholder = "[Image #1]"; + let second_text = format!("{second_placeholder} second"); + let second_elements = vec![TextElement::new( + (0..second_placeholder.len()).into(), + Some(second_placeholder.to_string()), + )]; + let second_images = [PathBuf::from("/tmp/second.png")]; + + let existing_placeholder = "[Image #1]"; + let existing_text = format!("{existing_placeholder} existing"); + let existing_elements = vec![TextElement::new( + (0..existing_placeholder.len()).into(), + Some(existing_placeholder.to_string()), + )]; + let existing_images = vec![PathBuf::from("/tmp/existing.png")]; + + chat.queued_user_messages.push_back(UserMessage { + text: first_text, + local_images: vec![LocalImageAttachment { + placeholder: first_placeholder.to_string(), + path: first_images[0].clone(), + }], + text_elements: first_elements, + }); + chat.queued_user_messages.push_back(UserMessage { + text: second_text, + local_images: vec![LocalImageAttachment { + placeholder: second_placeholder.to_string(), + path: second_images[0].clone(), + }], + text_elements: second_elements, + }); + chat.refresh_queued_user_messages(); + + chat.bottom_pane + .set_composer_text(existing_text, existing_elements, existing_images.clone()); + + // When interrupted, queued messages are merged into the composer; image placeholders + // must be renumbered to match the combined local image list. + chat.handle_codex_event(Event { + id: "interrupt".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + let first = "[Image #1] first".to_string(); + let second = "[Image #2] second".to_string(); + let third = "[Image #3] existing".to_string(); + let expected_text = format!("{first}\n{second}\n{third}"); + assert_eq!(chat.bottom_pane.composer_text(), expected_text); + + let first_start = 0; + let second_start = first.len() + 1; + let third_start = second_start + second.len() + 1; + let expected_elements = vec![ + TextElement::new( + (first_start..first_start + "[Image #1]".len()).into(), + Some("[Image #1]".to_string()), + ), + TextElement::new( + (second_start..second_start + "[Image #2]".len()).into(), + Some("[Image #2]".to_string()), + ), + TextElement::new( + (third_start..third_start + "[Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ]; + assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); + assert_eq!( + chat.bottom_pane.composer_local_image_paths(), + vec![ + first_images[0].clone(), + second_images[0].clone(), + existing_images[0].clone(), + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_attachment_labels() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement::new( + (0..placeholder_two.len()).into(), + Some(placeholder_two.to_string()), + ), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + Some(placeholder_one.to_string()), + ), + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + +#[tokio::test] +async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { + let placeholder_one = "[Image #1]"; + let placeholder_two = "[Image #2]"; + let text = format!("{placeholder_two} before {placeholder_one}"); + let elements = vec![ + TextElement::new((0..placeholder_two.len()).into(), None), + TextElement::new( + ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), + None, + ), + ]; + + let attachments = vec![ + LocalImageAttachment { + placeholder: placeholder_one.to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: placeholder_two.to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ]; + let message = UserMessage { + text, + text_elements: elements, + local_images: attachments, + }; + let mut next_label = 3usize; + let remapped = remap_placeholders_for_message(message, &mut next_label); + + assert_eq!(remapped.text, "[Image #4] before [Image #3]"); + assert_eq!( + remapped.text_elements, + vec![ + TextElement::new( + (0.."[Image #4]".len()).into(), + Some("[Image #4]".to_string()), + ), + TextElement::new( + ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), + Some("[Image #3]".to_string()), + ), + ] + ); + assert_eq!( + remapped.local_images, + vec![ + LocalImageAttachment { + placeholder: "[Image #3]".to_string(), + path: PathBuf::from("/tmp/one.png"), + }, + LocalImageAttachment { + placeholder: "[Image #4]".to_string(), + path: PathBuf::from("/tmp/two.png"), + }, + ] + ); +} + /// Entering review mode uses the hint provided by the review request. #[tokio::test] async fn entered_review_mode_uses_request_hint() { @@ -343,6 +702,7 @@ async fn helpers_are_available_and_do_not_panic() { let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), @@ -352,20 +712,35 @@ async fn helpers_are_available_and_do_not_panic() { config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, - initial_prompt: None, - initial_images: Vec::new(), + initial_user_message: None, enhanced_keys_supported: false, auth_manager, models_manager: thread_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, model: Some(resolved_model), + otel_manager, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. let _ = &mut w; } +fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + let model_info = ModelsManager::construct_model_info_offline(model, config); + OtelManager::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + None, + None, + None, + false, + "test".to_string(), + SessionSource::Cli, + ) +} + // --- Helpers for tests that need direct construction and event draining --- async fn make_chatwidget_manual( model_override: Option<&str>, @@ -384,6 +759,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -395,8 +771,27 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); + bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); + let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); + let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); + let reasoning_effort = None; + let stored_collaboration_mode = if collaboration_modes_enabled { + collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }) + } else { + CollaborationMode::Custom(Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }) + }; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -404,10 +799,10 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), + stored_collaboration_mode, + auth_manager, + models_manager, + otel_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, @@ -444,6 +839,7 @@ async fn make_chatwidget_manual( pre_review_token_info: None, needs_final_message_separator: false, had_work_activity: false, + last_separator_elapsed_secs: None, last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, @@ -474,6 +870,16 @@ fn set_chatgpt_auth(chat: &mut ChatWidget) { )); } +#[tokio::test] +async fn worked_elapsed_from_resets_when_timer_restarts() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + assert_eq!(chat.worked_elapsed_from(5), 5); + assert_eq!(chat.worked_elapsed_from(9), 4); + // Simulate status timer resetting (e.g., status indicator recreated for a new task). + assert_eq!(chat.worked_elapsed_from(3), 3); + assert_eq!(chat.worked_elapsed_from(7), 4); +} + pub(crate) async fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, @@ -1081,7 +1487,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { chat.thread_id = Some(ThreadId::new()); // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); + chat.bottom_pane + .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Simulate an active task so further submissions are queued. @@ -1115,7 +1522,7 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane - .set_composer_text("queued submission".to_string()); + .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); @@ -1358,9 +1765,105 @@ async fn unified_exec_end_after_task_complete_is_suppressed() { ); } +#[tokio::test] +async fn unified_exec_interaction_after_task_complete_is_suppressed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + chat.on_task_complete(None); + + chat.handle_codex_event(Event { + id: "call-1".to_string(), + msg: EventMsg::TerminalInteraction(TerminalInteractionEvent { + call_id: "call-1".to_string(), + process_id: "proc-1".to_string(), + stdin: "ls\n".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!( + cells.is_empty(), + "expected unified exec interaction after task complete to be suppressed" + ); +} + +#[tokio::test] +async fn unified_exec_wait_after_final_agent_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + model_context_window: None, + }), + }); + + begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core"); + terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", ""); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessage(AgentMessageEvent { + message: "Final response.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: Some("Final response.".into()), + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!("unified_exec_wait_after_final_agent_message", combined); +} + +#[tokio::test] +async fn unified_exec_wait_before_streamed_agent_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + model_context_window: None, + }), + }); + + begin_unified_exec_startup( + &mut chat, + "call-wait-stream", + "proc-1", + "cargo test -p codex-core", + ); + terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", ""); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: "Streaming response.".into(), + }), + }); + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + last_agent_message: None, + }), + }); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_snapshot!("unified_exec_wait_before_streamed_agent_message", combined); +} + #[tokio::test] async fn unified_exec_wait_status_header_updates_on_late_command_display() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); chat.unified_exec_processes.push(UnifiedExecProcessSummary { key: "proc-1".to_string(), command_display: "sleep 5".to_string(), @@ -1382,6 +1885,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-1", "proc-1", "just fix"); terminal_interaction(&mut chat, "call-wait-1a", "proc-1", ""); @@ -1409,6 +1913,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { #[tokio::test] async fn unified_exec_empty_then_non_empty_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-2", "proc-2", "just fix"); terminal_interaction(&mut chat, "call-wait-2a", "proc-2", ""); @@ -1425,6 +1930,7 @@ async fn unified_exec_empty_then_non_empty_snapshot() { #[tokio::test] async fn unified_exec_non_empty_then_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); begin_unified_exec_startup(&mut chat, "call-wait-3", "proc-3", "just fix"); terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n"); @@ -1522,84 +2028,79 @@ async fn slash_init_skips_when_project_doc_exists() { ); } -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - #[tokio::test] async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.collaboration_mode; + let initial = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); + assert_eq!(chat.stored_collaboration_mode, initial); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Execute(_) + )); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::Plan(_) + )); chat.on_task_started(); + let before = chat.stored_collaboration_mode.clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + assert_eq!(chat.stored_collaboration_mode, before); } #[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; +async fn collab_slash_command_opens_picker_and_updates_mode() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); + chat.dispatch_command(SlashCommand::Collab); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Select Collaboration Mode"), + "expected collaboration picker: {popup}" + ); - chat.bottom_pane.set_composer_text("hello".to_string()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let selected_mode = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + other => panic!("expected UpdateCollaborationMode event, got {other:?}"), + }; + chat.set_collaboration_mode(selected_mode); + + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } - chat.bottom_pane.set_composer_text("follow up".to_string()); + chat.bottom_pane + .set_composer_text("follow up".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), + collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } @@ -1609,17 +2110,30 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.bottom_pane.set_composer_text("hello".to_string()); + chat.bottom_pane + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode::PairProgramming(_)), .. } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), + other => { + panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + } } } +#[tokio::test] +async fn collab_mode_enabling_sets_pair_programming_default() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + assert!(matches!( + chat.stored_collaboration_mode, + CollaborationMode::PairProgramming(_) + )); +} + #[tokio::test] async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -2332,7 +2846,7 @@ async fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2346,7 +2860,7 @@ async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -2864,7 +3378,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane.set_task_running(true); chat.bottom_pane - .set_composer_text("current draft".to_string()); + .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.queued_user_messages .push_back(UserMessage::from("first queued".to_string())); @@ -2912,6 +3426,38 @@ async fn interrupt_clears_unified_exec_processes() { let _ = drain_insert_history(&mut rx); } +#[tokio::test] +async fn interrupt_clears_unified_exec_wait_streak_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + model_context_window: None, + }), + }); + + let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix"); + terminal_interaction(&mut chat, "call-1a", "process-1", ""); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + end_exec(&mut chat, begin, "", "", 0); + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + let snapshot = format!("cells={}\n{combined}", cells.len()); + assert_snapshot!("interrupt_clears_unified_exec_wait_streak", snapshot); +} + #[tokio::test] async fn turn_complete_clears_unified_exec_processes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3888,8 +4434,11 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() { delta: "**Investigating rendering code**".into(), }), }); - chat.bottom_pane - .set_composer_text("Summarize recent commits".to_string()); + chat.bottom_pane.set_composer_text( + "Summarize recent commits".to_string(), + Vec::new(), + Vec::new(), + ); let width: u16 = 80; let ui_height: u16 = chat.desired_height(width); diff --git a/codex-rs/tui/src/collab.rs b/codex-rs/tui/src/collab.rs index 5a1a18f63c..b6c7d80959 100644 --- a/codex-rs/tui/src/collab.rs +++ b/codex-rs/tui/src/collab.rs @@ -7,123 +7,122 @@ use codex_core::protocol::CollabAgentSpawnEndEvent; use codex_core::protocol::CollabCloseEndEvent; use codex_core::protocol::CollabWaitingBeginEvent; use codex_core::protocol::CollabWaitingEndEvent; +use codex_protocol::ThreadId; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Span; +use std::collections::HashMap; const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; +const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; +const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, new_thread_id, prompt, status, } = ev; let new_agent = new_thread_id - .map(|id| id.to_string()) - .unwrap_or_else(|| "none".to_string()); + .map(|id| Span::from(id.to_string())) + .unwrap_or_else(|| Span::from("not created").dim()); let mut details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("new_agent", new_agent), + detail_line("agent", new_agent), status_line(&status), ]; if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Collab spawn", details) + collab_event("Agent spawned", details) } pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { let CollabAgentInteractionEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, receiver_thread_id, prompt, status, } = ev; let mut details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", receiver_thread_id.to_string()), status_line(&status), ]; if let Some(line) = prompt_line(&prompt) { details.push(line); } - collab_event("Collab send input", details) + collab_event("Input sent", details) } pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { let CollabWaitingBeginEvent { call_id, - sender_thread_id, + sender_thread_id: _, receiver_thread_ids, } = ev; let details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", format!("{receiver_thread_ids:?}")), + detail_line("receivers", format_thread_ids(&receiver_thread_ids)), ]; - collab_event("Collab wait begin", details) + collab_event("Waiting for agents", details) } pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { let CollabWaitingEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, statuses, } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("statuses", format!("{statuses:#?}")), - ]; - collab_event("Collab wait end", details) + let mut details = vec![detail_line("call", call_id)]; + details.extend(wait_complete_lines(&statuses)); + collab_event("Wait complete", details) } pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { let CollabCloseEndEvent { call_id, - sender_thread_id, + sender_thread_id: _, receiver_thread_id, status, } = ev; let details = vec![ detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), + detail_line("receiver", receiver_thread_id.to_string()), status_line(&status), ]; - collab_event("Collab close", details) + collab_event("Agent closed", details) } fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { let title = title.into(); - let mut lines: Vec> = vec![vec!["• ".dim(), title.bold()].into()]; + let mut lines: Vec> = + vec![vec![Span::from("• ").dim(), Span::from(title).bold()].into()]; if !details.is_empty() { lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); } PlainHistoryCell::new(lines) } -fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> { - Line::from(format!("{label}: {value}").dim()) +fn detail_line(label: &str, value: impl Into>) -> Line<'static> { + vec![Span::from(format!("{label}: ")).dim(), value.into()].into() } fn status_line(status: &AgentStatus) -> Line<'static> { - Line::from(format!("status: {}", status_text(status)).dim()) + detail_line("status", status_span(status)) } -fn status_text(status: &AgentStatus) -> &'static str { +fn status_span(status: &AgentStatus) -> Span<'static> { match status { - AgentStatus::PendingInit => "pending_init", - AgentStatus::Running => "running", - AgentStatus::Completed(_) => "completed", - AgentStatus::Errored(_) => "errored", - AgentStatus::Shutdown => "shutdown", - AgentStatus::NotFound => "not_found", + AgentStatus::PendingInit => Span::from("pending init").dim(), + AgentStatus::Running => Span::from("running").cyan().bold(), + AgentStatus::Completed(_) => Span::from("completed").green(), + AgentStatus::Errored(_) => Span::from("errored").red(), + AgentStatus::Shutdown => Span::from("shutdown").dim(), + AgentStatus::NotFound => Span::from("not found").red(), } } @@ -134,7 +133,133 @@ fn prompt_line(prompt: &str) -> Option> { } else { Some(detail_line( "prompt", - truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES), + Span::from(truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES)).dim(), )) } } + +fn format_thread_ids(ids: &[ThreadId]) -> Span<'static> { + if ids.is_empty() { + return Span::from("none").dim(); + } + let joined = ids + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + Span::from(joined) +} + +fn wait_complete_lines(statuses: &HashMap) -> Vec> { + if statuses.is_empty() { + return vec![detail_line("agents", Span::from("none").dim())]; + } + + let mut pending_init = 0usize; + let mut running = 0usize; + let mut completed = 0usize; + let mut errored = 0usize; + let mut shutdown = 0usize; + let mut not_found = 0usize; + for status in statuses.values() { + match status { + AgentStatus::PendingInit => pending_init += 1, + AgentStatus::Running => running += 1, + AgentStatus::Completed(_) => completed += 1, + AgentStatus::Errored(_) => errored += 1, + AgentStatus::Shutdown => shutdown += 1, + AgentStatus::NotFound => not_found += 1, + } + } + + let mut summary = vec![Span::from(format!("{} total", statuses.len())).dim()]; + push_status_count( + &mut summary, + pending_init, + "pending init", + ratatui::prelude::Stylize::dim, + ); + push_status_count(&mut summary, running, "running", |span| span.cyan().bold()); + push_status_count( + &mut summary, + completed, + "completed", + ratatui::prelude::Stylize::green, + ); + push_status_count( + &mut summary, + errored, + "errored", + ratatui::prelude::Stylize::red, + ); + push_status_count( + &mut summary, + shutdown, + "shutdown", + ratatui::prelude::Stylize::dim, + ); + push_status_count( + &mut summary, + not_found, + "not found", + ratatui::prelude::Stylize::red, + ); + + let mut entries: Vec<(String, &AgentStatus)> = statuses + .iter() + .map(|(thread_id, status)| (thread_id.to_string(), status)) + .collect(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + + let mut lines = Vec::with_capacity(entries.len() + 1); + lines.push(detail_line_spans("agents", summary)); + lines.extend(entries.into_iter().map(|(thread_id, status)| { + let mut spans = vec![ + Span::from(thread_id).dim(), + Span::from(" ").dim(), + status_span(status), + ]; + match status { + AgentStatus::Completed(Some(message)) => { + let message_preview = truncate_text( + &message.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES, + ); + spans.push(Span::from(": ").dim()); + spans.push(Span::from(message_preview)); + } + AgentStatus::Errored(error) => { + let error_preview = truncate_text( + &error.split_whitespace().collect::>().join(" "), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES, + ); + spans.push(Span::from(": ").dim()); + spans.push(Span::from(error_preview).dim()); + } + _ => {} + } + spans.into() + })); + lines +} + +fn push_status_count( + spans: &mut Vec>, + count: usize, + label: &'static str, + style: impl FnOnce(Span<'static>) -> Span<'static>, +) { + if count == 0 { + return; + } + + spans.push(Span::from(" · ").dim()); + spans.push(style(Span::from(format!("{count} {label}")))); +} + +fn detail_line_spans(label: &str, mut value: Vec>) -> Line<'static> { + let mut spans = Vec::with_capacity(value.len() + 1); + spans.push(Span::from(format!("{label}: ")).dim()); + spans.append(&mut value); + spans.into() +} diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index bdd1d8b63c..f48ad92c91 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,135 +1,49 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; use codex_core::models_manager::manager::ModelsManager; use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ModeKind { Plan, - #[default] PairProgramming, Execute, + Custom, } -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } +fn mode_kind(mode: &CollaborationMode) -> ModeKind { + match mode { + CollaborationMode::Plan(_) => ModeKind::Plan, + CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, + CollaborationMode::Execute(_) => ModeKind::Execute, + CollaborationMode::Custom(_) => ModeKind::Custom, } } -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } +pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { + let presets = models_manager.list_collaboration_modes(); + presets + .iter() + .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .cloned() + .or_else(|| presets.into_iter().next()) } -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( +pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { + mode_kind(a) == mode_kind(b) +} + +/// Cycle to the next collaboration mode preset in list order. +pub(crate) fn next_mode( models_manager: &ModelsManager, - selection: Selection, + current: &CollaborationMode, ) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), + let presets = models_manager.list_collaboration_modes(); + if presets.is_empty() { + return None; } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) + let current_kind = mode_kind(current); + let next_index = presets + .iter() + .position(|preset| mode_kind(preset) == current_kind) + .map_or(0, |idx| (idx + 1) % presets.len()); + presets.get(next_index).cloned() } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 07c036c373..a33820d1c2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -19,6 +19,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::key_hint; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -43,10 +44,13 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::user_input::TextElement; +use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -54,6 +58,7 @@ use mcp_types::Resource; use mcp_types::ResourceLink; use mcp_types::ResourceTemplate; use ratatui::prelude::*; +use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; @@ -158,6 +163,75 @@ impl dyn HistoryCell { #[derive(Debug)] pub(crate) struct UserHistoryCell { pub message: String, + pub text_elements: Vec, + #[allow(dead_code)] + pub local_image_paths: Vec, +} + +/// Build logical lines for a user message with styled text elements. +/// +/// This preserves explicit newlines while interleaving element spans and skips +/// malformed byte ranges instead of panicking during history rendering. +fn build_user_message_lines_with_elements( + message: &str, + elements: &[TextElement], + style: Style, + element_style: Style, +) -> Vec> { + let mut elements = elements.to_vec(); + elements.sort_by_key(|e| e.byte_range.start); + let mut offset = 0usize; + let mut raw_lines: Vec> = Vec::new(); + for line_text in message.split('\n') { + let line_start = offset; + let line_end = line_start + line_text.len(); + let mut spans: Vec> = Vec::new(); + // Track how much of the line we've emitted to interleave plain and styled spans. + let mut cursor = line_start; + for elem in &elements { + let start = elem.byte_range.start.max(line_start); + let end = elem.byte_range.end.min(line_end); + if start >= end { + continue; + } + let rel_start = start - line_start; + let rel_end = end - line_start; + // Guard against malformed UTF-8 byte ranges from upstream data; skip + // invalid elements rather than panicking while rendering history. + if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { + continue; + } + let rel_cursor = cursor - line_start; + if cursor < start + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..rel_start) + { + spans.push(Span::from(segment.to_string())); + } + if let Some(segment) = line_text.get(rel_start..rel_end) { + spans.push(Span::styled(segment.to_string(), element_style)); + cursor = end; + } + } + let rel_cursor = cursor - line_start; + if cursor < line_end + && line_text.is_char_boundary(rel_cursor) + && let Some(segment) = line_text.get(rel_cursor..) + { + spans.push(Span::from(segment.to_string())); + } + let line = if spans.is_empty() { + Line::from(line_text.to_string()).style(style) + } else { + Line::from(spans).style(style) + }; + raw_lines.push(line); + // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts + // for the separator byte. + offset = line_end + 1; + } + + raw_lines } impl HistoryCell for UserHistoryCell { @@ -171,13 +245,28 @@ impl HistoryCell for UserHistoryCell { .max(1); let style = user_message_style(); + let element_style = style.fg(Color::Cyan); - let wrapped = word_wrap_lines( - self.message.lines().map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); + let wrapped = if self.text_elements.is_empty() { + word_wrap_lines( + self.message.split('\n').map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + } else { + let raw_lines = build_user_message_lines_with_elements( + &self.message, + &self.text_elements, + style, + element_style, + ); + word_wrap_lines( + raw_lines, + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + }; lines.push(Line::from("").style(style)); lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); @@ -817,6 +906,8 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -829,6 +920,8 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, + is_collaboration, + collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -886,8 +979,16 @@ pub(crate) fn new_session_info( SessionInfoCell(CompositeHistoryCell { parts }) } -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } +pub(crate) fn new_user_prompt( + message: String, + text_elements: Vec, + local_image_paths: Vec, +) -> UserHistoryCell { + UserHistoryCell { + message, + text_elements, + local_image_paths, + } } #[derive(Debug)] @@ -897,6 +998,8 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, + is_collaboration: bool, + collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -905,6 +1008,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -912,6 +1017,8 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, + is_collaboration, + collaboration_mode, ) } @@ -921,6 +1028,8 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, + is_collaboration: bool, + collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -928,6 +1037,20 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, + is_collaboration, + collaboration_mode, + } + } + + fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.is_collaboration { + return None; + } + match &self.collaboration_mode { + CollaborationMode::Plan(_) => Some("Plan"), + CollaborationMode::PairProgramming(_) => Some("Pair Programming"), + CollaborationMode::Execute(_) => Some("Execute"), + CollaborationMode::Custom(_) => None, } } @@ -988,25 +1111,51 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_label = format!( - "{model_label:> = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - model_spans.push(Span::from(" ")); - model_spans.push(Span::from(reasoning)); - } - model_spans.push(" ".dim()); - model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); + + let model_spans: Vec> = if self.is_collaboration { + // Render collaboration mode instead of model + let collab_label = format!( + "{collab_label: = key_hint::shift(KeyCode::Tab).into(); + spans.push(shift_tab_span.cyan()); + spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); + spans + } else { + // Render model as before + let model_label = format!( + "{model_label:, - active_profile: Option, feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -404,15 +397,15 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, ); let login_status = get_login_status(&initial_config); - let should_show_trust_screen = should_show_trust_screen(&initial_config); + let should_show_trust_screen_flag = should_show_trust_screen(&initial_config); let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen); + should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen: should_show_login_screen(login_status, &initial_config), - show_trust_screen: should_show_trust_screen, + show_trust_screen: should_show_trust_screen_flag, login_status, auth_manager: auth_manager.clone(), config: initial_config.clone(), @@ -437,7 +430,7 @@ async fn run_ratatui_app( .map(|d| d == TrustDirectorySelection::Trust) .unwrap_or(false) { - load_config_or_exit(cli_kv_overrides, overrides).await + load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await } else { initial_config } @@ -570,6 +563,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let config = match &session_selection { + resume_picker::SessionSelection::Resume(path) + | resume_picker::SessionSelection::Fork(path) => { + let history_cwd = match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + }; + load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + history_cwd, + ) + .await + } + _ => config, + }; + let active_profile = config.active_profile.clone(); + let should_show_trust_screen = should_show_trust_screen(&config); + let Cli { prompt, images, @@ -671,9 +691,23 @@ fn get_login_status(config: &Config) -> LoginStatus { async fn load_config_or_exit( cli_kv_overrides: Vec<(String, toml::Value)>, overrides: ConfigOverrides, +) -> Config { + load_config_or_exit_with_fallback_cwd(cli_kv_overrides, overrides, None).await +} + +async fn load_config_or_exit_with_fallback_cwd( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, + fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { + match ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .fallback_cwd(fallback_cwd) + .build() + .await + { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 2a80c087ee..46a7e72bcf 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -48,13 +48,14 @@ impl ComposerInput { /// Clear the input text. pub fn clear(&mut self) { - self.inner.set_text_content(String::new()); + self.inner + .set_text_content(String::new(), Vec::new(), Vec::new()); } /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted { text, .. } => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 1f903c26b7..5e2fe57b8a 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -5,7 +5,8 @@ use crate::history_cell::with_border_with_inner_width; use crate::version::CODEX_CLI_VERSION; use chrono::DateTime; use chrono::Local; -use codex_common::create_config_summary_entries; +use codex_common::summarize_sandbox_policy; +use codex_core::WireApi; use codex_core::config::Config; use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; @@ -13,6 +14,7 @@ use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::openai_models::ReasoningEffort; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; @@ -85,6 +87,7 @@ pub(crate) fn new_status_output( now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( @@ -99,6 +102,7 @@ pub(crate) fn new_status_output( now, model_name, collaboration_mode, + reasoning_effort_override, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -118,8 +122,29 @@ impl StatusHistoryCell { now: DateTime, model_name: &str, collaboration_mode: Option<&str>, + reasoning_effort_override: Option>, ) -> Self { - let config_entries = create_config_summary_entries(config, model_name); + let mut config_entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", model_name.to_string()), + ("provider", config.model_provider_id.clone()), + ("approval", config.approval_policy.value().to_string()), + ( + "sandbox", + summarize_sandbox_policy(config.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + let effort_value = reasoning_effort_override + .unwrap_or(None) + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()); + config_entries.push(("reasoning effort", effort_value)); + config_entries.push(( + "reasoning summaries", + config.model_reasoning_summary.to_string(), + )); + } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index bc421da424..92ed340817 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -95,7 +95,6 @@ async fn status_snapshot_includes_reasoning_details() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config .sandbox_policy @@ -141,6 +140,7 @@ async fn status_snapshot_includes_reasoning_details() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -153,6 +153,7 @@ async fn status_snapshot_includes_reasoning_details() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -205,6 +206,7 @@ async fn status_snapshot_includes_forked_from() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -263,6 +265,7 @@ async fn status_snapshot_includes_monthly_limit() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -309,6 +312,7 @@ async fn status_snapshot_shows_unlimited_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -354,6 +358,7 @@ async fn status_snapshot_shows_positive_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -399,6 +404,7 @@ async fn status_snapshot_hides_zero_credits() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -442,6 +448,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() { captured_at, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -485,6 +492,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { now, &model_slug, None, + None, ); let rendered = render_lines(&composite.display_lines(120)); @@ -500,7 +508,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = ReasoningSummary::Detailed; config.cwd = PathBuf::from("/workspace/tests"); @@ -531,6 +538,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); + let reasoning_effort_override = Some(Some(ReasoningEffort::High)); let composite = new_status_output( &config, &auth_manager, @@ -543,6 +551,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { captured_at, &model_slug, None, + reasoning_effort_override, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -590,6 +599,7 @@ async fn status_snapshot_shows_missing_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -655,6 +665,7 @@ async fn status_snapshot_includes_credits_and_limits() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -708,6 +719,7 @@ async fn status_snapshot_shows_empty_limits_message() { captured_at, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -770,6 +782,7 @@ async fn status_snapshot_shows_stale_limits_message() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -836,6 +849,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { now, &model_slug, None, + None, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -892,6 +906,7 @@ async fn status_context_window_uses_last_usage() { now, &model_slug, None, + None, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index e50d59791e..26a71bd897 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -19,10 +19,10 @@ pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { - let top = if is_light(terminal_bg) { - (0, 0, 0) + let (top, alpha) = if is_light(terminal_bg) { + ((0, 0, 0), 0.04) } else { - (255, 255, 255) + ((255, 255, 255), 0.12) }; - best_color(blend(top, terminal_bg, 0.1)) + best_color(blend(top, terminal_bg, alpha)) } diff --git a/codex-rs/tui2/BUILD.bazel b/codex-rs/tui2/BUILD.bazel deleted file mode 100644 index 70c977b6a3..0000000000 --- a/codex-rs/tui2/BUILD.bazel +++ /dev/null @@ -1,20 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "tui2", - crate_name = "codex_tui2", - compile_data = glob( - include = ["**"], - exclude = [ - "**/* *", - "BUILD.bazel", - "Cargo.toml", - ], - allow_empty = True, - ), - test_data_extra = glob(["src/**/snapshots/**"]), - integration_compile_data_extra = ["src/test_backend.rs"], - extra_binaries = [ - "//codex-rs/cli:codex", - ], -) diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml deleted file mode 100644 index 6abc7c1a4a..0000000000 --- a/codex-rs/tui2/Cargo.toml +++ /dev/null @@ -1,124 +0,0 @@ -[package] -name = "codex-tui2" -version.workspace = true -edition.workspace = true -license.workspace = true - -[[bin]] -name = "codex-tui2" -path = "src/main.rs" - -[lib] -name = "codex_tui2" -path = "src/lib.rs" - -[features] -# Enable vt100-based tests (emulator) when running with `--features vt100-tests`. -vt100-tests = [] -# Gate verbose debug logging inside the TUI implementation. -debug-logs = [] - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -async-stream = { workspace = true } -base64 = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -clap = { workspace = true, features = ["derive"] } -codex-ansi-escape = { workspace = true } -codex-app-server-protocol = { workspace = true } -codex-arg0 = { workspace = true } -codex-backend-client = { workspace = true } -codex-chatgpt = { workspace = true } -codex-common = { workspace = true, features = [ - "cli", - "elapsed", - "sandbox_summary", -] } -codex-core = { workspace = true } -codex-feedback = { workspace = true } -codex-file-search = { workspace = true } -codex-login = { workspace = true } -codex-protocol = { workspace = true } -codex-utils-absolute-path = { workspace = true } -codex-tui = { workspace = true } -color-eyre = { workspace = true } -crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } -derive_more = { workspace = true, features = ["is_variant"] } -diffy = { workspace = true } -dirs = { workspace = true } -dunce = { workspace = true } -image = { workspace = true, features = ["jpeg", "png"] } -itertools = { workspace = true } -lazy_static = { workspace = true } -mcp-types = { workspace = true } -pathdiff = { workspace = true } -pulldown-cmark = { workspace = true } -rand = { workspace = true } -ratatui = { workspace = true, features = [ - "scrolling-regions", - "unstable-backend-writer", - "unstable-rendered-line-info", - "unstable-widget-ref", -] } -ratatui-core = { workspace = true } -ratatui-macros = { workspace = true } -regex-lite = { workspace = true } -reqwest = { version = "0.12", features = ["json"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true, features = ["preserve_order"] } -shlex = { workspace = true } -strum = { workspace = true } -strum_macros = { workspace = true } -supports-color = { workspace = true } -tempfile = { workspace = true } -textwrap = { workspace = true } -tui-scrollbar = { workspace = true } -tokio = { workspace = true, features = [ - "io-std", - "macros", - "process", - "rt-multi-thread", - "signal", - "test-util", - "time", -] } -tokio-stream = { workspace = true } -toml = { workspace = true } -toml_edit = { workspace = true } -tracing = { workspace = true, features = ["log"] } -tracing-appender = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter"] } -tree-sitter-bash = { workspace = true } -tree-sitter-highlight = { workspace = true } -unicode-segmentation = { workspace = true } -unicode-width = { workspace = true } -url = { workspace = true } - -codex-windows-sandbox = { workspace = true } -tokio-util = { workspace = true, features = ["time"] } - -[target.'cfg(unix)'.dependencies] -libc = { workspace = true } - -# Clipboard support via `arboard` is not available on Android/Termux. -# Only include it for non-Android targets so the crate builds on Android. -[target.'cfg(not(target_os = "android"))'.dependencies] -arboard = { workspace = true } - - -[dev-dependencies] -assert_matches = { workspace = true } -codex-cli = { workspace = true } -codex-core = { workspace = true, features = ["test-support"] } -codex-utils-cargo-bin = { workspace = true } -codex-utils-pty = { workspace = true } -chrono = { workspace = true, features = ["serde"] } -insta = { workspace = true } -pretty_assertions = { workspace = true } -rand = { workspace = true } -serial_test = { workspace = true } -vt100 = { workspace = true } -uuid = { workspace = true } diff --git a/codex-rs/tui2/docs/scroll_input_model.md b/codex-rs/tui2/docs/scroll_input_model.md deleted file mode 100644 index f6eef6a6cb..0000000000 --- a/codex-rs/tui2/docs/scroll_input_model.md +++ /dev/null @@ -1,610 +0,0 @@ -# TUI2 Scroll Input: Model and Implementation - -This is the single "scrolling doc of record" for TUI2. - -It describes what we implemented, why it works, and what we tried before this approach. -It also preserves the scroll-probe findings (see Appendix) that motivated the model. - -Code reference: `codex-rs/tui2/src/tui/scrolling/mouse.rs`. - -## Goals and constraints - -Goals: - -- Mouse wheel: scroll about **3 transcript lines per physical wheel tick** regardless of terminal - event density (classic feel). -- Trackpad: remain **higher fidelity**, meaning small movements can accumulate fractionally and - should not be forced into wheel behavior. -- Work across terminals where a single wheel tick may produce 1, 3, 9, or more raw events. - -Constraints: - -- Terminals typically encode both wheels and trackpads as the same "scroll up/down" mouse button - events without a magnitude. We cannot reliably observe device type directly. -- Timing alone is not a reliable discriminator (wheel and trackpad bursts overlap). - -## Current implementation (stream-based; data-driven) - -TUI2 uses a stream model: scroll events are grouped into short streams separated by silence. -Within a stream, we normalize by a per-terminal "events per tick" factor and then apply either -wheel-like (fixed lines per tick) or trackpad-like (fractional) semantics. - -### 1. Stream detection - -- A stream begins on the first scroll event. -- A stream ends when the gap since the last event exceeds `STREAM_GAP_MS` or when direction flips. -- Direction flips always close the current stream and start a new one, so we never blend "up" and - "down" into a single accumulator. - -This makes behavior stable across: - -- Dense bursts (Warp/Ghostty-style sub-ms intervals). -- Sparse bursts (single events separated by tens or hundreds of ms). -- Mixed wheel + trackpad input where direction changes quickly. - -### 2. Normalization: events-per-tick - -Different terminals emit different numbers of raw events per physical wheel notch. -We normalize by converting raw events into tick-equivalents: - -`tick_equivalents = raw_events / events_per_tick` - -Per-terminal defaults come from the probe logs (Appendix), and users can override them. - -Config key: `tui.scroll_events_per_tick`. - -### 3. Wheel vs trackpad behavior (and why it is heuristic) - -Because device type is not directly observable, the implementation provides a mode setting: - -- `tui.scroll_mode = "auto"` (default): infer wheel-like vs trackpad-like behavior per stream. -- `tui.scroll_mode = "wheel"`: always treat streams as wheel-like. -- `tui.scroll_mode = "trackpad"`: always treat streams as trackpad-like. - -In auto mode: - -- Streams start trackpad-like (safer: avoids overshoot when we guess wrong). -- Streams promote to wheel-like when the first tick-worth of events arrives quickly. -- For 1-event-per-tick terminals, "first tick completion time" is not observable, so there is a - conservative end-of-stream fallback for very small bursts. - -This design assumes that auto classification is a best-effort heuristic and must be overridable. - -### 4. Applying scroll: wheel-like streams - -Wheel-like streams target the "classic feel" requirement. - -- Each raw event contributes `tui.scroll_wheel_lines / events_per_tick` lines. -- Deltas flush immediately (not cadence-gated) so wheels feel snappy even on dense streams. -- Wheel-like streams apply a minimum +/- 1 line when events were received but rounding would yield 0. - -Defaults: - -- `tui.scroll_wheel_lines = 3` - -### 5. Applying scroll: trackpad-like streams - -Trackpad-like streams are designed for fidelity first. - -- Each raw event contributes `tui.scroll_trackpad_lines / trackpad_events_per_tick` lines. -- Fractional remainder is carried across streams, so tiny gestures accumulate instead of being lost. -- Trackpad deltas are cadence-gated to ~60 Hz (`REDRAW_CADENCE_MS`) to avoid redraw floods and to - reduce "stop lag" / overshoot. -- Trackpad streams intentionally do not apply a minimum +/- 1 line at stream end; if a gesture is - small enough to round to 0, it should feel like "no movement", not a forced jump. - -Dense wheel terminals (e.g. Ghostty/Warp) can emit trackpad streams with high event density. -Using a wheel-derived `events_per_tick = 9` for trackpad would make trackpads feel slow, so we use -a capped divisor for trackpad normalization: - -- `trackpad_events_per_tick = min(events_per_tick, 3)` - -Additionally, to keep small gestures precise while making large/fast swipes cover more content, -trackpad-like streams apply bounded acceleration based on event count: - -- `tui.scroll_trackpad_accel_events`: how many events correspond to +1x multiplier. -- `tui.scroll_trackpad_accel_max`: maximum multiplier. - -### 6. Guard rails and axis handling - -- Horizontal scroll events are ignored for vertical scrolling. -- Streams clamp event counts and accumulated line deltas to avoid floods. - -## Terminal defaults and per-terminal tuning - -Defaults are keyed by `TerminalName` (terminal family), not exact version. -Probe data is version-specific, so defaults should be revalidated as more logs arrive. - -Events-per-tick defaults derived from `wheel_single` medians: - -- AppleTerminal: 3 -- WarpTerminal: 9 -- WezTerm: 1 -- Alacritty: 3 -- Ghostty: 3 -- Iterm2: 1 -- VsCode: 1 -- Kitty: 3 -- Unknown: 3 - -Note: probe logs measured Ghostty at ~9 events per tick, but we default to 3 because an upstream -Ghostty change is expected to reduce wheel event density. Users can override with -`tui.scroll_events_per_tick`. - -Auto-mode wheel promotion thresholds can also be tuned per terminal if needed (see config below). - -## Configuration knobs (TUI2) - -These are user-facing knobs in `config.toml` under `[tui]`: - -In this repo, "tick" always refers to a physical mouse wheel notch. Trackpads do not have ticks, so -trackpad settings are expressed in terms of "tick-equivalents" (raw events normalized to a common -scale). - -The core normalization formulas are: - -- Wheel-like streams: - - `lines_per_event = scroll_wheel_lines / scroll_events_per_tick` -- Trackpad-like streams: - - `lines_per_event = scroll_trackpad_lines / min(scroll_events_per_tick, 3)` - - (plus bounded acceleration from `scroll_trackpad_accel_*` and fractional carry across streams) - -Keys: - -- `scroll_events_per_tick` (number): - - Raw vertical scroll events per physical wheel notch in your terminal (normalization input). - - Affects wheel-like scroll speed and auto-mode wheel promotion timing. - - Trackpad-like mode uses `min(..., 3)` as the divisor so dense wheel ticks (e.g. 9 events per - notch) do not make trackpads feel artificially slow. -- `scroll_wheel_lines` (number): - - Lines per physical wheel notch (default 3). - - Change this if you want "classic" wheel scrolling to be more/less aggressive globally. -- `scroll_trackpad_lines` (number): - - Baseline trackpad sensitivity in trackpad-like mode (default 1). - - Change this if your trackpad feels consistently too slow/fast for small motions. -- `scroll_trackpad_accel_events` (number): - - Trackpad acceleration tuning (default 30). Smaller values accelerate earlier. - - Trackpad-like streams compute a multiplier: - - `multiplier = clamp(1 + abs(events) / scroll_trackpad_accel_events, 1..scroll_trackpad_accel_max)` - - The multiplier is applied to the trackpad stream’s computed line delta (including any carried - fractional remainder). -- `scroll_trackpad_accel_max` (number): - - Trackpad acceleration cap (default 3). Set to 1 to effectively disable acceleration. -- `scroll_mode` (`auto` | `wheel` | `trackpad`): - - `auto` (default): infer wheel-like vs trackpad-like per stream. - - `wheel`: always wheel-like (good for wheel-only setups; trackpads will feel jumpy). - - `trackpad`: always trackpad-like (good if auto misclassifies; wheels may feel slow). -- `scroll_wheel_tick_detect_max_ms` (number): - - Auto-mode promotion threshold: how quickly the first tick-worth of events must arrive to - consider the stream wheel-like. - - If wheel feels slow in a dense-wheel terminal, increasing this is usually better than changing - `scroll_events_per_tick`. -- `scroll_wheel_like_max_duration_ms` (number): - - Auto-mode fallback for 1-event-per-tick terminals (WezTerm/iTerm/VS Code). - - If wheel feels like trackpad (too slow) in those terminals, increasing this can help. -- `scroll_invert` (bool): - - Invert direction after terminal detection; applies consistently to wheel and trackpad. - -## Previous approaches tried (and why they were replaced) - -1. Cadence-based inference (rolling inter-event thresholds) - -- Approach: infer wheel vs trackpad using inter-event timing thresholds (burst vs frame cadence vs slow), - with terminal-specific tuning. -- Problem: terminals differ more in event density and batching than in timing; timing overlaps heavily - between wheel and trackpad. Small threshold changes had outsized, terminal-specific effects. - -2. Pure event-count or pure duration classification - -- Approach: classify wheel-like vs trackpad-like by event count <= N or duration <= M. -- Problem: burst length overlaps heavily across devices/terminals; duration is more separable but still - not strong enough to be authoritative. - -3. Why streams + normalization won - -- Streams give a stable unit ("what did the user do in one gesture?") that we can bound and reason about. -- Normalization directly addresses the main cross-terminal source of variation: raw event density. -- Classification remains heuristic, but is isolated and configurable. - -## Appendix A: Follow-up analysis (latest log per terminal; 2025-12-20) - -This section is derived from a "latest log per terminal" subset analysis. The exact event count is -not significant; it is included only as a note about which subset was used. - -Key takeaways: - -- Burst length overlaps heavily between wheel and trackpad. Simple "event count <= N" classifiers perform poorly. -- Burst span (duration) is more separable: wheel bursts typically complete in < ~180-200 ms, while trackpad - bursts are often hundreds of milliseconds. -- Conclusion: explicit wheel vs trackpad classification is inherently weak from these events; prefer a - stream model, plus a small heuristic and a config override (`tui.scroll_mode`) for edge cases. - -Data notes (latest per terminal label): - -- Logs used (one per terminal, by filename timestamp): - - mouse_scroll_log_Apple_Terminal_2025-12-19T19-53-54Z.jsonl - - mouse_scroll_log_WarpTerminal_2025-12-19T19-59-38Z.jsonl - - mouse_scroll_log_WezTerm_2025-12-19T20-00-36Z.jsonl - - mouse_scroll_log_alacritty_2025-12-19T19-56-45Z.jsonl - - mouse_scroll_log_ghostty_2025-12-19T19-52-44Z.jsonl - - mouse_scroll_log_iTerm_app_2025-12-19T19-55-08Z.jsonl - - mouse_scroll_log_vscode_2025-12-19T19-51-20Z.jsonl - - mouse_scroll_log_xterm-kitty_2025-12-19T19-58-19Z.jsonl - -Per-terminal burst separability (wheel vs trackpad), summarized as median and p90: - -- Apple Terminal: - - Wheel: length median 9.5 (p90 49), span median 94 ms (p90 136) - - Trackpad: length median 13.5 (p90 104), span median 238 ms (p90 616) -- Warp: - - Wheel: length median 43 (p90 169), span median 88 ms (p90 178) - - Trackpad: length median 60 (p90 82), span median 358 ms (p90 721) -- WezTerm: - - Wheel: length median 4 (p90 10), span median 91 ms (p90 156) - - Trackpad: length median 10.5 (p90 36), span median 270 ms (p90 348) -- alacritty: - - Wheel: length median 14 (p90 63), span median 109 ms (p90 158) - - Trackpad: length median 12.5 (p90 63), span median 372 ms (p90 883) -- ghostty: - - Wheel: length median 32.5 (p90 163), span median 99 ms (p90 157) - - Trackpad: length median 14.5 (p90 60), span median 366 ms (p90 719) -- iTerm: - - Wheel: length median 4 (p90 9), span median 91 ms (p90 230) - - Trackpad: length median 9 (p90 36), span median 223 ms (p90 540) -- VS Code: - - Wheel: length median 3 (p90 9), span median 94 ms (p90 120) - - Trackpad: length median 3 (p90 12), span median 192 ms (p90 468) -- Kitty: - - Wheel: length median 15.5 (p90 59), span median 87 ms (p90 233) - - Trackpad: length median 15.5 (p90 68), span median 292 ms (p90 563) - -Wheel_single medians (events per tick) in the latest logs: - -- Apple: 3 -- Warp: 9 -- WezTerm: 1 -- alacritty: 3 -- ghostty: 9 (measured); TUI2 defaults use 3 because an upstream Ghostty change is expected to - reduce wheel event density. If your Ghostty build still emits ~9 events per wheel tick, set - `tui.scroll_events_per_tick = 9`. -- iTerm: 1 -- VS Code: 1 -- Kitty: 3 - -## Appendix B: Scroll probe findings (authoritative; preserved verbatim) - -The remainder of this document is preserved from the original scroll-probe spec. -It is intentionally not rewritten so the data and rationale remain auditable. - -Note: the original text uses "events per line" terminology; the implementation treats this as an -events-per-wheel-tick normalization factor (see "Normalization: events-per-tick"). - -Note: the pseudocode in the preserved spec is not the exact current implementation; it is kept as -historical context for how the probe data originally mapped into an algorithm. The current -implementation is described in the sections above. - -## 1. TL;DR - -Analysis of 16 scroll-probe logs (13,734 events) across 8 terminals shows large per-terminal variation in how many raw events are emitted per physical wheel tick (1-9+ events). Timing alone does not distinguish wheel vs trackpad; event counts and burst duration are more reliable. The algorithm below treats scroll input as short streams separated by gaps, normalizes events into line deltas using a per-terminal events-per-line factor, coalesces redraws at 60 Hz, and applies a minimum 1-line delta for discrete bursts. This yields stable behavior across dense streams, sparse bursts, and terminals that emit horizontal events. - -## 2. Data overview - -- Logs analyzed: 16 -- Total events: 13,734 -- Terminals covered: - - Apple_Terminal 455.1 - - WarpTerminal v0.2025.12.17.17.stable_02 - - WezTerm 20240203-110809-5046fc22 - - alacritty - - ghostty 1.2.3 - - iTerm.app 3.6.6 - - vscode 1.107.1 - - xterm-kitty -- Scenarios captured: `wheel_single`, `wheel_small`, `wheel_long`, `trackpad_single`, `trackpad_slow`, `trackpad_fast` (directional up/down variants treated as distinct bursts). -- Legacy `wheel_scroll_*` logs are mapped to `wheel_small` in analysis. - -## 3. Cross-terminal comparison table - -| Terminal | Scenario | Median Dt (ms) | P95 Dt (ms) | Typical burst | Notes | -| --------------------------------------- | --------------- | -------------: | ----------: | ------------: | ----------- | -| Apple_Terminal 455.1 | wheel_single | 0.14 | 97.68 | 3 | -| Apple_Terminal 455.1 | wheel_small | 0.12 | 23.81 | 19 | -| Apple_Terminal 455.1 | wheel_long | 0.03 | 15.93 | 48 | -| Apple_Terminal 455.1 | trackpad_single | 92.35 | 213.15 | 2 | -| Apple_Terminal 455.1 | trackpad_slow | 11.30 | 75.46 | 14 | -| Apple_Terminal 455.1 | trackpad_fast | 0.13 | 8.92 | 96 | -| WarpTerminal v0.2025.12.17.17.stable_02 | wheel_single | 0.07 | 0.34 | 9 | -| WarpTerminal v0.2025.12.17.17.stable_02 | wheel_small | 0.05 | 5.04 | 65 | -| WarpTerminal v0.2025.12.17.17.stable_02 | wheel_long | 0.01 | 0.42 | 166 | -| WarpTerminal v0.2025.12.17.17.stable_02 | trackpad_single | 9.77 | 32.64 | 10 | -| WarpTerminal v0.2025.12.17.17.stable_02 | trackpad_slow | 7.93 | 16.44 | 74 | -| WarpTerminal v0.2025.12.17.17.stable_02 | trackpad_fast | 5.40 | 10.04 | 74 | -| WezTerm 20240203-110809-5046fc22 | wheel_single | 416.07 | 719.64 | 1 | -| WezTerm 20240203-110809-5046fc22 | wheel_small | 19.41 | 50.19 | 6 | -| WezTerm 20240203-110809-5046fc22 | wheel_long | 13.19 | 29.96 | 10 | -| WezTerm 20240203-110809-5046fc22 | trackpad_single | 237.56 | 237.56 | 1 | -| WezTerm 20240203-110809-5046fc22 | trackpad_slow | 23.54 | 76.10 | 10 | 12.5% horiz | -| WezTerm 20240203-110809-5046fc22 | trackpad_fast | 7.10 | 24.86 | 32 | 12.6% horiz | -| alacritty | wheel_single | 0.09 | 0.33 | 3 | -| alacritty | wheel_small | 0.11 | 37.24 | 24 | -| alacritty | wheel_long | 0.01 | 15.96 | 56 | -| alacritty | trackpad_single | n/a | n/a | 1 | -| alacritty | trackpad_slow | 41.90 | 97.36 | 11 | -| alacritty | trackpad_fast | 3.07 | 25.13 | 62 | -| ghostty 1.2.3 | wheel_single | 0.05 | 0.20 | 9 | -| ghostty 1.2.3 | wheel_small | 0.05 | 7.18 | 52 | -| ghostty 1.2.3 | wheel_long | 0.02 | 1.16 | 146 | -| ghostty 1.2.3 | trackpad_single | 61.28 | 124.28 | 3 | 23.5% horiz | -| ghostty 1.2.3 | trackpad_slow | 23.10 | 76.30 | 14 | 34.7% horiz | -| ghostty 1.2.3 | trackpad_fast | 3.84 | 37.72 | 47 | 23.4% horiz | -| iTerm.app 3.6.6 | wheel_single | 74.96 | 80.61 | 1 | -| iTerm.app 3.6.6 | wheel_small | 20.79 | 84.83 | 6 | -| iTerm.app 3.6.6 | wheel_long | 16.70 | 50.91 | 9 | -| iTerm.app 3.6.6 | trackpad_single | n/a | n/a | 1 | -| iTerm.app 3.6.6 | trackpad_slow | 17.25 | 94.05 | 9 | -| iTerm.app 3.6.6 | trackpad_fast | 7.12 | 24.54 | 33 | -| vscode 1.107.1 | wheel_single | 58.01 | 58.01 | 1 | -| vscode 1.107.1 | wheel_small | 16.76 | 66.79 | 5 | -| vscode 1.107.1 | wheel_long | 9.86 | 32.12 | 8 | -| vscode 1.107.1 | trackpad_single | n/a | n/a | 1 | -| vscode 1.107.1 | trackpad_slow | 164.19 | 266.90 | 3 | -| vscode 1.107.1 | trackpad_fast | 16.78 | 61.05 | 11 | -| xterm-kitty | wheel_single | 0.16 | 51.74 | 3 | -| xterm-kitty | wheel_small | 0.10 | 24.12 | 26 | -| xterm-kitty | wheel_long | 0.01 | 16.10 | 56 | -| xterm-kitty | trackpad_single | 155.65 | 289.87 | 1 | 12.5% horiz | -| xterm-kitty | trackpad_slow | 16.89 | 67.04 | 16 | 30.4% horiz | -| xterm-kitty | trackpad_fast | 0.23 | 16.37 | 78 | 20.6% horiz | - -## 4. Key findings - -- Raw wheel ticks vary by terminal: median events per tick are 1 (WezTerm/iTerm/vscode), 3 (Apple/alacritty/kitty), and 9 (Warp/ghostty). -- Trackpad bursts are longer than wheel ticks but overlap in timing; inter-event timing alone does not distinguish device type. -- Continuous streams have short gaps: overall inter-event p99 is 70.67 ms; trackpad_slow p95 is 66.98 ms. -- Horizontal events appear only in trackpad scenarios and only in WezTerm/ghostty/kitty; ignore horizontal events for vertical scrolling. -- Burst duration is a reliable discrete/continuous signal: - - wheel_single median 0.15 ms (p95 80.61 ms) - - trackpad_single median 0 ms (p95 237.56 ms) - - wheel_small median 96.88 ms (p95 182.90 ms) - - trackpad_slow median 320.69 ms (p95 812.10 ms) - -## 5. Scrolling model (authoritative) - -**Stream detection.** Treat scroll input as short streams separated by silence. A stream begins on the first scroll event and ends when the gap since the last event exceeds `STREAM_GAP_MS` or the direction flips. Direction flip immediately closes the current stream and starts a new one. - -**Normalization.** Convert raw events into line deltas using a per-terminal `EVENTS_PER_LINE` factor derived from the terminal's median `wheel_single` burst length. If no terminal override matches, use the global default (`3`). - -**Discrete vs continuous.** Classify the stream after it ends: - -- If `event_count <= DISCRETE_MAX_EVENTS` **and** `duration_ms <= DISCRETE_MAX_DURATION_MS`, treat as discrete. -- Otherwise treat as continuous. - -**Discrete streams.** Apply the accumulated line delta immediately. If the stream's accumulated lines rounds to 0 but events were received, apply a minimum +/-1 line (respecting direction). - -**Continuous streams.** Accumulate fractional lines and coalesce redraws to `REDRAW_CADENCE_MS`. Flush any remaining fractional lines on stream end (with the same +/-1 minimum if the stream had events but rounded to 0). - -**Direction.** Always use the raw event direction. Provide a separate user-level invert option if needed; do not infer inversion from timing. - -**Horizontal events.** Ignore horizontal events in vertical scroll logic. - -## 6. Concrete constants (data-derived) - -```text -STREAM_GAP_MS = 80 -DISCRETE_MAX_EVENTS = 10 -DISCRETE_MAX_DURATION_MS = 250 -REDRAW_CADENCE_MS = 16 -DEFAULT_EVENTS_PER_LINE = 3 -MAX_EVENTS_PER_STREAM = 256 -MAX_ACCUMULATED_LINES = 256 -MIN_LINES_PER_DISCRETE_STREAM = 1 -DEFAULT_WHEEL_LINES_PER_TICK = 3 -``` - -Why these values: - -- `STREAM_GAP_MS=80`: overall p99 inter-event gap is 70.67 ms; trackpad_slow p95 is 66.98 ms. 80 ms ends streams without splitting most continuous input. -- `DISCRETE_MAX_EVENTS=10`: wheel_single p95 burst = 9; trackpad_single p95 burst = 10. -- `DISCRETE_MAX_DURATION_MS=250`: trackpad_single p95 duration = 237.56 ms. -- `REDRAW_CADENCE_MS=16`: coalesces dense streams to ~60 Hz; trackpad_fast p95 Dt = 19.83 ms. -- `DEFAULT_EVENTS_PER_LINE=3`: global median wheel_single burst length. -- `MAX_EVENTS_PER_STREAM=256` and `MAX_ACCUMULATED_LINES=256`: highest observed burst is 206; cap to avoid floods. -- `DEFAULT_WHEEL_LINES_PER_TICK=3`: restores classic wheel speed; this is a UX choice rather than a data-derived constant. - -## 7. Pseudocode (Rust-oriented) - -```rust -// This is intentionally a simplified sketch of the current implementation. -// For the authoritative behavior, see `codex-rs/tui2/src/tui/scrolling/mouse.rs`. - -enum StreamKind { - Unknown, - Wheel, - Trackpad, -} - -struct Stream { - start: Instant, - last: Instant, - dir: i32, - event_count: usize, - accumulated_events: i32, - applied_lines: i32, - kind: StreamKind, - just_promoted: bool, -} - -struct State { - stream: Option, - carry_lines: f32, - last_redraw_at: Instant, - cfg: Config, -} - -struct Config { - events_per_tick: u16, - wheel_lines_per_tick: u16, - trackpad_lines_per_tick: u16, - trackpad_accel_events: u16, - trackpad_accel_max: u16, - wheel_tick_detect_max: Duration, -} - -fn on_scroll_event(dir: i32, now: Instant, st: &mut State) -> i32 { - // Close stream on idle gap or direction flip. - if let Some(stream) = st.stream.as_ref() { - let gap = now.duration_since(stream.last); - if gap > STREAM_GAP || stream.dir != dir { - finalize_stream(now, st); - st.stream = None; - } - } - - let stream = st.stream.get_or_insert_with(|| Stream { - start: now, - last: now, - dir, - event_count: 0, - accumulated_events: 0, - applied_lines: 0, - kind: StreamKind::Unknown, - just_promoted: false, - }); - - stream.last = now; - stream.dir = dir; - stream.event_count = (stream.event_count + 1).min(MAX_EVENTS_PER_STREAM); - stream.accumulated_events = - (stream.accumulated_events + dir).clamp(-(MAX_EVENTS_PER_STREAM as i32), MAX_EVENTS_PER_STREAM as i32); - - // Auto-mode promotion: promote to wheel-like when the first tick-worth of events arrives quickly. - if matches!(stream.kind, StreamKind::Unknown) { - let ept = st.cfg.events_per_tick.max(1) as usize; - if ept >= 2 && stream.event_count >= ept && now.duration_since(stream.start) <= st.cfg.wheel_tick_detect_max { - stream.kind = StreamKind::Wheel; - stream.just_promoted = true; - } - } - - flush_lines(now, st) -} - -fn on_tick(now: Instant, st: &mut State) -> i32 { - if let Some(stream) = st.stream.as_ref() { - let gap = now.duration_since(stream.last); - if gap > STREAM_GAP { - return finalize_stream(now, st); - } - } - flush_lines(now, st) -} - -fn finalize_stream(now: Instant, st: &mut State) -> i32 { - // In auto mode, any stream that isn't wheel-like by promotion stays trackpad-like. - if let Some(stream) = st.stream.as_mut() { - if matches!(stream.kind, StreamKind::Unknown) { - stream.kind = StreamKind::Trackpad; - } - } - - let lines = flush_lines(now, st); - - // Carry fractional remainder across streams for trackpad-like input. - if let Some(stream) = st.stream.as_ref() { - if matches!(stream.kind, StreamKind::Trackpad) { - st.carry_lines = desired_lines_f32(st, stream) - stream.applied_lines as f32; - } else { - st.carry_lines = 0.0; - } - } - - lines -} - -fn flush_lines(now: Instant, st: &mut State) -> i32 { - let Some(stream) = st.stream.as_mut() else { return 0; }; - - let wheel_like = matches!(stream.kind, StreamKind::Wheel); - let cadence_elapsed = now.duration_since(st.last_redraw_at) >= REDRAW_CADENCE; - let should_flush = wheel_like || cadence_elapsed || stream.just_promoted; - if !should_flush { - return 0; - } - - let desired_total = desired_lines_f32(st, stream); - let mut desired_lines = desired_total.trunc() as i32; - - // Wheel guardrail: ensure we never produce a "dead tick" for non-zero input. - if wheel_like && desired_lines == 0 && stream.accumulated_events != 0 { - desired_lines = stream.accumulated_events.signum() * MIN_LINES_PER_DISCRETE_STREAM; - } - - let mut delta = desired_lines - stream.applied_lines; - if delta == 0 { - return 0; - } - - delta = delta.clamp(-MAX_ACCUMULATED_LINES, MAX_ACCUMULATED_LINES); - stream.applied_lines += delta; - stream.just_promoted = false; - st.last_redraw_at = now; - delta -} - -fn desired_lines_f32(st: &State, stream: &Stream) -> f32 { - let wheel_like = matches!(stream.kind, StreamKind::Wheel); - - let events_per_tick = if wheel_like { - st.cfg.events_per_tick.max(1) as f32 - } else { - // Trackpad divisor is capped so dense wheel terminals don't feel slow for trackpads. - st.cfg.events_per_tick.clamp(1, DEFAULT_EVENTS_PER_LINE).max(1) as f32 - }; - - let lines_per_tick = if wheel_like { - st.cfg.wheel_lines_per_tick.max(1) as f32 - } else { - st.cfg.trackpad_lines_per_tick.max(1) as f32 - }; - - let mut total = (stream.accumulated_events as f32 * (lines_per_tick / events_per_tick)) - .clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32); - - if !wheel_like { - total = (total + st.carry_lines).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32); - - // Bounded acceleration for large swipes (keep small swipes precise). - let event_count = stream.accumulated_events.abs() as f32; - let accel = (1.0 + (event_count / st.cfg.trackpad_accel_events.max(1) as f32)) - .clamp(1.0, st.cfg.trackpad_accel_max.max(1) as f32); - total = (total * accel).clamp(-(MAX_ACCUMULATED_LINES as f32), MAX_ACCUMULATED_LINES as f32); - } - - total -} -``` - -## 8. Terminal-specific adjustments (minimal) - -Use per-terminal `EVENTS_PER_LINE` overrides derived from median `wheel_single` bursts: - -```text -Apple_Terminal 455.1 = 3 -WarpTerminal v0.2025.12.17.17.stable_02 = 9 -WezTerm 20240203-110809-5046fc22 = 1 -alacritty = 3 -ghostty 1.2.3 = 9 -iTerm.app 3.6.6 = 1 -vscode 1.107.1 = 1 -xterm-kitty = 3 -``` - -If terminal is not matched, use `DEFAULT_EVENTS_PER_LINE = 3`. - -## 9. Known weird cases and guardrails - -- Extremely dense streams (sub-ms Dt) occur in Warp/ghostty/kitty; redraw coalescing is mandatory. -- Sparse bursts (hundreds of ms between events) occur in trackpad_single; do not merge them into long streams. -- Horizontal scroll events (12-35% of trackpad events in some terminals) must be ignored for vertical scrolling. -- Direction inversion is user-configurable in terminals; always use event direction and expose an application-level invert setting. -- Guard against floods: cap event counts and accumulated line deltas per stream. diff --git a/codex-rs/tui2/docs/streaming_wrapping_design.md b/codex-rs/tui2/docs/streaming_wrapping_design.md deleted file mode 100644 index f7af4ccccb..0000000000 --- a/codex-rs/tui2/docs/streaming_wrapping_design.md +++ /dev/null @@ -1,169 +0,0 @@ -# Streaming Wrapping Reflow (tui2) - -This document describes a correctness bug in `codex-rs/tui2` and the chosen fix: -while streaming assistant markdown, soft-wrap decisions were effectively persisted as hard line -breaks, so resizing the viewport could not reflow prose. - -## Goal - -- Resizing the viewport reflows transcript prose (including streaming assistant output). -- Width-derived breaks are always treated as *soft wraps* (not logical newlines). -- Copy/paste continues to treat soft wraps as joinable (via joiners), and hard breaks as newlines. - -Non-goals: - -- Reflowing terminal scrollback that has already been printed. -- Reflowing content that is intentionally treated as preformatted (e.g., code blocks, raw stdout). - -## Background: where reflow happens in tui2 - -TUI2 renders the transcript as a list of `HistoryCell`s: - -1. A cell stores width-agnostic content (string, diff, logical lines, etc.). -2. At draw time (and on resize), `transcript_render` asks each cell for lines at the *current* - width (ideally via `HistoryCell::transcript_lines_with_joiners(width)`). -3. `TranscriptViewCache` caches the wrapped visual lines keyed by width; a width change triggers a - rebuild. - -This only works if cells do *not* persist width-derived wrapping inside their stored state. - -## The bug: soft wraps became hard breaks during streaming - -Ratatui represents multi-line content as `Vec`. If we split a paragraph into multiple `Line`s -because the viewport is narrow, that split is indistinguishable from an explicit newline unless we -also carry metadata describing which breaks were “soft”. - -Streaming assistant output used to generate already-wrapped `Line`s and store them inside the -history cell. Later, when the viewport became wider, the transcript renderer could not “un-split” -those baked lines — they looked like hard breaks. - -## Chosen solution (A, F1): stream logical markdown lines; wrap in the cell at render-time - -User choice recap: - -- **A**: Keep append-only streaming (new history cell per commit tick), but make the streamed data - width-agnostic. -- **F1**: Make the agent message cell responsible for wrapping-to-width so transcript-level wrapping - can be a no-op for it. - -### Key idea: separate markdown parsing from wrapping - -We introduce a width-agnostic “logical markdown line” representation that preserves the metadata -needed to wrap correctly later: - -- `codex-rs/tui2/src/markdown_render.rs` - - `MarkdownLogicalLine { content, initial_indent, subsequent_indent, line_style, is_preformatted }` - - `render_markdown_logical_lines(input: &str) -> Vec` - -This keeps: - -- hard breaks (paragraph/list boundaries, explicit newlines), -- markdown indentation rules for wraps (list markers, nested lists, blockquotes), -- preformatted runs (code blocks) stable. - -### Updated streaming pipeline - -- `codex-rs/tui2/src/markdown_stream.rs` - - `MarkdownStreamCollector` is newline-gated (no change), but now commits - `Vec` instead of already-wrapped `Vec`. - - Width is removed from the collector; wrapping is not performed during streaming. - -- `codex-rs/tui2/src/streaming/controller.rs` - - Emits `AgentMessageCell::new_logical(...)` containing logical lines. - -- `codex-rs/tui2/src/history_cell.rs` - - `AgentMessageCell` stores `Vec`. - - `HistoryCell::transcript_lines_with_joiners(width)` wraps each logical line at the current - width using `word_wrap_line_with_joiners` and composes indents as: - - transcript gutter prefix (`• ` / ` `), plus - - markdown-provided initial/subsequent indents. - - Preformatted logical lines are rendered without wrapping. - -Result: on resize, the transcript cache rebuilds against the new width and the agent output reflows -correctly because the stored content contains no baked soft wraps. - -## Overlay deferral fix (D): defer cells, not rendered lines - -When an overlay (transcript/static) is active, TUI2 is in alt screen and the normal terminal buffer -is not visible. Historically, `tui2` attempted to queue “history to print” for the normal buffer by -deferring *rendered lines*, which baked the then-current width. - -User choice recap: - -- **D**: Store deferred *cells* and render them at overlay close time. - -Implementation: - -- `codex-rs/tui2/src/app.rs` - - `deferred_history_cells: Vec>` (replaces `deferred_history_lines`). - - `AppEvent::InsertHistoryCell` pushes cells into the deferral list when `overlay.is_some()`. - -- `codex-rs/tui2/src/app_backtrack.rs` - - `close_transcript_overlay` renders deferred cells at the *current* width when closing the - overlay, then queues the resulting lines for the normal terminal buffer. - -Note: as of today, `Tui::insert_history_lines` queues lines but `Tui::draw` does not flush them into -the terminal (see `codex-rs/tui2/src/tui.rs`). This section is therefore best read as “behavior we -want when/if scrollback printing is re-enabled”, not a guarantee that content is printed during the -main TUI loop. For the current intended behavior around printing, see -`codex-rs/tui2/docs/tui_viewport_and_history.md`. - -## Tests (G2) - -User choice recap: - -- **G2**: Add resize reflow tests + snapshot coverage. - -Added coverage: - -- `codex-rs/tui2/src/history_cell.rs` - - `agent_message_cell_reflows_streamed_prose_on_resize` - - `agent_message_cell_reflows_streamed_prose_vt100_snapshot` - -These assert that a streamed agent cell produces fewer visual lines at wider widths and provide -snapshots showing reflow for list items and blockquotes. - -## Audit: other `HistoryCell`s and width-baked paths - -This section answers “what else might behave like this?” up front. - -### History cells - -- `AgentMessageCell` (`codex-rs/tui2/src/history_cell.rs`): **was affected**; now stores logical - markdown lines and wraps at render time. -- `UserHistoryCell` (`codex-rs/tui2/src/history_cell.rs`): wraps at render time from stored `String` - using `word_wrap_lines_with_joiners` (reflowable). -- `ReasoningSummaryCell` (`codex-rs/tui2/src/history_cell.rs`): renders from stored `String` on each - call; it does call `append_markdown(..., Some(width))`, but that wrapping is recomputed per width - (reflowable). -- `PrefixedWrappedHistoryCell` (`codex-rs/tui2/src/history_cell.rs`): wraps at render time and - returns joiners (reflowable). -- `PlainHistoryCell` (`codex-rs/tui2/src/history_cell.rs`): stores `Vec` and returns it - unchanged (not reflowable by design; used for already-structured/preformatted output). - -Rule of thumb: any cell that stores already-wrapped `Vec` for prose is a candidate for the -same bug; cells that store source text or logical lines and compute wrapping inside -`display_lines(width)` are safe. - -### Width-baked output outside the transcript model - -Even with the streaming fix, some paths are inherently width-baked: - -- Printed transcript after exit (`codex-rs/tui2/src/app.rs`): `AppExitInfo.session_lines` is rendered - once using the final width and then printed; it cannot reflow afterward. -- Optional scrollback insertion helper (`codex-rs/tui2/src/insert_history.rs`): once ANSI is written - to the terminal, that output cannot be reflowed later. This helper is currently used for - deterministic ANSI emission (`write_spans`) and tests; it is not wired into the main TUI draw - loop. -- Static overlays (`codex-rs/tui2/src/pager_overlay.rs`): reflow depends on whether callers provided - width-agnostic input; pre-split `Vec` cannot be “un-split” within the overlay. - -## Deferred / follow-ups - -The fix above is sufficient to unblock correct reflow on resize. Remaining choices can be deferred: - -- Streaming granularity: one logical line can wrap into multiple visual lines, so “commit tick” - updates can appear in larger chunks than before. If this becomes a UX issue, we can add a render- - time “progressive reveal” layer without reintroducing width baking. -- Expand logical-line rendering to other markdown-ish cells if needed (e.g., unify `append_markdown` - usage), but only if we find a concrete reflow bug beyond `AgentMessageCell`. diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md deleted file mode 100644 index 50d23ffa14..0000000000 --- a/codex-rs/tui2/docs/tui_viewport_and_history.md +++ /dev/null @@ -1,564 +0,0 @@ -# TUI2 Viewport, Transcript, and History – Design Notes - -This document describes the viewport and history model we are implementing in the new -`codex-rs/tui2` crate. It builds on lessons from the legacy TUI and explains why we moved away -from directly writing history into terminal scrollback. - -The target audience is Codex developers and curious contributors who want to understand or -critique how TUI2 owns its viewport, scrollback, and suspend behavior. - -Unless stated otherwise, references to “the TUI” in this document mean the TUI2 implementation; -when we mean the legacy TUI specifically, we call it out explicitly. - ---- - -## 1. Problem Overview - -Historically, the legacy TUI tried to “cooperate” with the terminal’s own scrollback: - -- The inline viewport sat somewhere above the bottom of the screen. -- When new history arrived, we tried to insert it directly into the terminal scrollback above the - viewport. -- On certain transitions (e.g. switching sessions, overlays), we cleared and re‑wrote portions of - the screen from scratch. - -This had several failure modes: - -- **Terminal‑dependent behavior.** - - - Different terminals handle scroll regions, clears, and resize semantics differently. - - What looked correct in one terminal could drop or duplicate content in another. - -- **Resizes and layout churn.** - - - The TUI reacts to resizes, focus changes, and overlay transitions. - - When the viewport moved or its size changed, our attempts to keep scrollback “aligned” with the - in‑memory history could go out of sync. - - In practice this meant: - - Some lines were lost or overwritten. - - Others were duplicated or appeared in unexpected places. - -- **“Clear and rewrite everything” didn’t save us.** - - We briefly tried a strategy of clearing large regions (or the full screen) and re‑rendering - history when the layout changed. - - This ran into two issues: - - Terminals treat full clears differently. For example, Terminal.app often leaves the cleared - screen as a “page” at the top of scrollback, some terminals interpret only a subset of the - ANSI clear/scrollback codes, and others (like iTerm2) gate “clear full scrollback” behind - explicit user consent. - - Replaying a long session is expensive and still subject to timing/race conditions with user - output (e.g. shell prompts) when we weren’t in alt screen. - -The net result: the legacy TUI could not reliably guarantee “the history you see on screen is complete, in -order, and appears exactly once” across terminals, resizes, suspend/resume, and overlay transitions. - ---- - -## 2. Goals - -The redesign is guided by a few explicit goals: - -1. **Codex, not the terminal, owns the viewport.** - - - The in‑memory transcript (a list of history entries) is the single source of truth for what’s - on screen. - - The TUI decides how to map that transcript into the current viewport; scrollback becomes an - output target, not an extra data structure we try to maintain. - -2. **History must be correct, ordered, and never silently dropped.** - - - Every logical history cell should either: - - Be visible in the TUI, or - - Have been printed into scrollback as part of a suspend/exit flow. - - We would rather (rarely) duplicate content than risk losing it. - -3. **Avoid unnecessary duplication.** - - - When emitting history to scrollback (on suspend or exit), print each logical cell’s content at - most once. - - Streaming cells are allowed to be “re‑seen” as they grow, but finished cells should not keep - reappearing. - -4. **Behave sensibly under resizes.** - - - TUI rendering should reflow to the current width on every frame. - - History printed to scrollback may have been wrapped at different widths over time; that is - acceptable, but it must not cause missing content or unbounded duplication. - -5. **Suspend/alt‑screen interaction is predictable.** - - `Ctrl+Z` should: - - Cleanly exit alt screen, if active. - - Print a consistent transcript prefix into normal scrollback. - - Resume with the TUI fully redrawn, without stale artifacts. - ---- - -## 3. New Viewport & Transcript Model - -### 3.1 Transcript as a logical sequence of cells - -At a high level, the TUI transcript is a list of “cells”, each representing one logical thing in -the conversation: - -- A user prompt (with padding and a distinct background). -- An agent response (which may arrive in multiple streaming chunks). -- System or info rows (session headers, migration banners, reasoning summaries, etc.). - -Each cell knows how to draw itself for a given width: how many lines it needs, what prefixes to -use, how to style its content. The transcript itself is purely logical: - -- It has no scrollback coordinates or terminal state baked into it. -- It can be re‑rendered for any viewport width. - -The TUI’s job is to take this logical sequence and decide how much of it fits into the current -viewport, and how it should be wrapped and styled on screen. - -### 3.2 Building viewport lines from the transcript - -To render the main transcript area above the composer, the TUI: - -1. Defines a “transcript region” as the full frame minus the height of the bottom input area. -2. Flattens all cells into a list of visual lines, remembering for each visual line which cell it - came from and which line within that cell it corresponds to. -3. Uses this flattened list plus a scroll position to decide which visual line should appear at the - top of the region. -4. Clears the transcript region and draws the visible slice of lines into it. -5. For user messages, paints the entire row background (including padding lines) so the user block - stands out even when it does not fill the whole width. -6. Applies selection styling and other overlays on top of the rendered lines. - -Scrolling (mouse wheel, PgUp/PgDn, Home/End) operates entirely in terms of these flattened lines -and the current scroll anchor. The terminal’s own scrollback is not part of this calculation; it -only ever sees fully rendered frames. - -### 3.3 Alternate screen, overlays, and redraw guarantees - -The TUI uses the terminal’s alternate screen for: - -- The main interactive chat session (so the viewport can cover the full terminal). -- Full‑screen overlays such as the transcript pager, diff view, model migration screen, and - onboarding. - -Conceptually: - -- Entering alt screen: - - - Switches the terminal into alt screen and expands the viewport to cover the full terminal. - - Clears that alt‑screen buffer. - -- Leaving alt screen: - - - Disables “alternate scroll” so mouse wheel events behave predictably. - - Returns to the normal screen. - -- On leaving overlays and on resuming from suspend, the TUI viewport is explicitly cleared and fully - redrawn: - - This prevents stale overlay content or shell output from lingering in the TUI area. - - The next frame reconstructs the UI entirely from the in‑memory transcript and other state, not - from whatever the terminal happened to remember. - -Alt screen is therefore treated as a temporary render target. The only authoritative copy of the UI -is the in‑memory state. - ---- - -## 4. Mouse, Selection, and Scrolling - -Mouse interaction is a first‑class part of the new design: - -- **Scrolling.** - - - Mouse wheel scrolls the transcript in fixed line increments. - - Keyboard shortcuts (PgUp/PgDn/Home/End) use the same scroll model, so the footer can show - consistent hints regardless of input device. - -- **Selection.** - - - A click‑and‑drag gesture defines a linear text selection in terms of the flattened transcript - lines (not raw buffer coordinates). - - Selection tracks the _content_ rather than a fixed screen row. When the transcript scrolls, the - selection moves along with the underlying lines instead of staying glued to a particular Y - position. - - The selection only covers the “transcript text” area; it intentionally skips the left gutter - that we use for bullets/prefixes. - -- **Copy.** - - When the user triggers copy, the TUI reconstructs the wrapped transcript lines using the same - flattening/wrapping rules as the visible view. - - It then reconstructs a high‑fidelity clipboard string from the selected logical lines: - - Preserves meaningful indentation (especially for code blocks). - - Treats soft-wrapped prose as a single logical line by joining wrap continuations instead of - inserting hard newlines. - - Emits Markdown source markers (e.g. backticks and fences) for copy/paste, even if the UI - chooses to render those constructs without showing the literal markers. - - Copy operates on the full selection range, even if the selection extends outside the current - viewport. - - The resulting text is sent to the system clipboard and a status footer indicates success or - failure. - -Because scrolling, selection, and copy all operate on the same flattened transcript representation, -they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our -own scrolling also means we must own mouse interactions end‑to‑end: if we left scrolling entirely -to the terminal, we could not reliably line up selections with transcript content or avoid -accidentally copying gutter/margin characters instead of just the conversation text. - -Scroll normalization details and the data behind it live in -`codex-rs/tui2/docs/scroll_input_model.md`. - ---- - -## 5. Printing History to Scrollback - -We still want the final session (and suspend points) to appear in the user’s normal scrollback, but -we no longer try to maintain scrollback in lock‑step with the TUI frame. Instead, we treat -scrollback as an **append‑only log** of logical transcript cells. - -In practice this means: - -- The TUI may print history both when you suspend (`Ctrl+Z`) and when you exit. -- Some users may prefer to only print on exit (for example to keep scrollback quieter during long - sessions). The current design anticipates gating suspend‑time printing behind a config toggle so - that this behavior can be made opt‑in or opt‑out without touching the core viewport logic, but - that switch has not been implemented yet. - -### 5.1 Cell‑based high‑water mark - -Internally, the TUI keeps a simple “high‑water mark” for history printing: - -- Think of this as “how many cells at the front of the transcript have already been sent to - scrollback.” -- It is just a counter over the logical transcript, not over wrapped lines. -- It moves forward only when we have actually printed more history. - -This means we never try to guess “how many terminal lines have already been printed”; we only -remember that “the first N logical entries are done.” - -### 5.2 Rendering new cells for scrollback - -When we need to print history (on suspend or exit), we: - -1. Take the suffix of the transcript that lies beyond the high‑water mark. -2. Render just that suffix into styled lines at the **current** terminal width. -3. Write those lines to stdout. -4. Advance the high‑water mark to include all cells we just printed. - -Older cells are never re‑rendered for scrollback; they remain in whatever wrapping they had when -they were first printed. This avoids the line‑count–based bugs we had before while still allowing -the on‑screen TUI to reflow freely. - -### 5.3 Suspend (`Ctrl+Z`) flow - -On suspend (typically `Ctrl+Z` on Unix): - -- Before yielding control back to the shell, the TUI: - - Leaves alt screen if it is active and restores normal terminal modes. - - Determines which transcript cells have not yet been printed and renders them for the current - width. - - Prints those new lines once into normal scrollback. - - Marks those cells as printed in the high‑water mark. - - Finally, sends the process to the background. - -On `fg`, the process resumes, re‑enters TUI modes, and redraws the viewport from the in‑memory -transcript. The history printed during suspend stays in scrollback and is not touched again. - -### 5.4 Exit flow - -When the TUI exits, we follow the same principle: - -- We compute the suffix of the transcript that has not yet been printed (taking into account any - prior suspends). -- We render just that suffix to styled lines at the current width. -- The outer `main` function leaves alt screen, restores the terminal, and prints those lines, plus a - blank line and token usage summary. - -If you never suspended, exit prints the entire transcript once. If you did suspend one or more -times, exit prints only the cells appended after the last suspend. In both cases, each logical -conversation entry reaches scrollback exactly once. - ---- - -## 6. Streaming, Width Changes, and Tradeoffs - -### 6.1 Streaming cells - -Streaming agent responses are represented as a sequence of history entries: - -- The first chunk produces a “first line” entry for the message. -- Subsequent chunks produce continuation entries that extend that message. - -From the history/scrollback perspective: - -- Each streaming chunk is just another entry in the logical transcript. -- The high‑water mark is a simple count of how many entries at the _front_ of the transcript have - already been printed. -- As new streaming chunks arrive, they are appended as new entries and will be included the next - time we print history on suspend or exit. - -We do **not** attempt to reprint or retroactively merge older chunks. In scrollback you will see the -streaming response as a series of discrete blocks, matching the internal history structure. - -Today, streaming rendering still “bakes in” some width at the time chunks are committed: line breaks -for the streaming path are computed using the width that was active at the time, and stored in the -intermediate representation. This is a known limitation and is called out in more detail in -`codex-rs/tui2/docs/streaming_wrapping_design.md`; a follow‑up change will make streaming behavior -match the rest of the transcript more closely (wrap only at display time, not at commit time). - -### 6.2 Width changes over time - -Because we now use a **cell‑level** high‑water mark instead of a visual line‑count, width changes -are handled gracefully: - -- On every suspend/exit, we render the not‑yet‑printed suffix of the transcript at the **current** - width and append those lines. -- Previously printed entries remain in scrollback with whatever wrapping they had at the time they - were printed. -- We no longer rely on “N lines printed before, therefore skip N lines of the newly wrapped - transcript,” which was the source of dropped and duplicated content when widths changed. - -This does mean scrollback can contain older cells wrapped for narrower or wider widths than the -final terminal size, but: - -- Each logical cell’s content appears exactly once. -- New cells are append‑only and never overwrite or implicitly “shrink” earlier content. -- The on‑screen TUI always reflows to the current width independently of scrollback. - -If we later choose to also re‑emit the “currently streaming” cell when printing on suspend (to make -sure the latest chunk of a long answer is always visible in scrollback), that would intentionally -duplicate a small number of lines at the boundary of that cell. The design assumes any such behavior -would be controlled by configuration (for example, by disabling suspend‑time printing entirely for -users who prefer only exit‑time output). - -### 6.3 Why not reflow scrollback? - -In theory we could try to reflow already‑printed content when widths change by: - -- Recomputing the entire transcript at the new width, and -- Printing diffs that “rewrite” old regions in scrollback. - -In practice, this runs into the same issues that motivated the redesign: - -- Terminals treat full clears and scroll regions differently. -- There is no portable way to “rewrite” arbitrary portions of scrollback above the visible buffer. -- Interleaving user output (e.g. shell prompts after suspend) makes it impossible to reliably - reconstruct the original scrollback structure. - -We therefore deliberately accept that scrollback is **append‑only** and not subject to reflow; -correctness is measured in terms of logical transcript content, not pixel‑perfect layout. - ---- - -## 7. Backtrack and Overlays (Context) - -While this document is focused on viewport and history, it’s worth mentioning a few related -behaviors that rely on the same model. - -### 7.1 Transcript overlay and backtrack - -The transcript overlay (pager) is a full‑screen view of the same logical transcript: - -- When opened, it takes a snapshot of the current transcript and renders it in an alt‑screen - overlay. -- Backtrack mode (`Esc` sequences) walks backwards through user messages in that snapshot and - highlights the candidate “edit from here” point. -- Confirming a backtrack request forks the conversation on the server and trims the in‑memory - transcript so that only history up to the chosen user message remains, then re‑renders that prefix - in the main view. - -The overlay is purely a different _view_ of the same transcript; it never infers anything from -scrollback. - ---- - -## 8. Summary of Tradeoffs - -**What we gain:** - -- The TUI has a clear, single source of truth for history (the in‑memory transcript). -- Viewport rendering is deterministic and independent of scrollback. -- Suspend and exit flows: - - Print each logical history cell exactly once. - - Are robust to terminal width changes. - - Interact cleanly with alt screen and raw‑mode toggling. -- Streaming, overlays, selection, and backtrack all share the same logical history model. -- Because cells are always re‑rendered live from the transcript, per‑cell interactions can become - richer over time. Instead of treating the transcript as “dead text”, we can make individual - entries interactive after they are rendered: expanding or contracting tool calls, diffs, or - reasoning summaries in place, jum…truncated… \*\*\* - ---- - -## 9. TUI2 Implementation Notes - -This section maps the design above onto the `codex-rs/tui2` crate so future viewport work has -concrete code pointers. - -### 9.1 Transcript state and layout - -The main app struct (`codex-rs/tui2/src/app.rs`) tracks the transcript and viewport state with: - -- `transcript_cells: Vec>` – the logical history. -- `transcript_scroll: TranscriptScroll` – whether the viewport is pinned to the bottom or - anchored at a specific cell/line pair. -- `transcript_selection: TranscriptSelection` – a selection expressed in content-relative - coordinates over the flattened, wrapped transcript (line index + column). -- `transcript_view_top` / `transcript_total_lines` – the current viewport’s top line index and - total number of wrapped lines for the inline transcript area. - -### 9.2 Rendering, wrapping, and selection - -`App::render_transcript_cells` defines the transcript region, builds flattened lines via -`App::build_transcript_lines`, wraps them with `word_wrap_lines_borrowed` from -`codex-rs/tui2/src/wrapping.rs`, and applies selection via `apply_transcript_selection` before -writing to the frame buffer. - -Streaming wrapping details live in `codex-rs/tui2/docs/streaming_wrapping_design.md`. - -### 9.3 Input, selection, and footer state - -Mouse handling lives in `App::handle_mouse_event`, keyboard scrolling in -`App::handle_key_event`, selection rendering in `App::apply_transcript_selection`, and copy in -`App::copy_transcript_selection` plus `codex-rs/tui2/src/transcript_selection.rs` and -`codex-rs/tui2/src/clipboard_copy.rs`. Scroll/selection UI state is forwarded through -`ChatWidget::set_transcript_ui_state`, -`BottomPane::set_transcript_ui_state`, and `ChatComposer::footer_props`, with footer text -assembled in `codex-rs/tui2/src/bottom_pane/footer.rs`. - -### 9.4 Exit transcript output - -`App::run` returns `session_lines` on `AppExitInfo` after flattening with -`App::build_transcript_lines` and converting to ANSI via `App::render_lines_to_ansi`. The CLI -prints those lines before the token usage and resume hints. - -## 10. Future Work and Open Questions - -### 10.1 Current status - -This design shipped behind the `tui2` feature flag (as a separate crate, duplicating the legacy -`tui` crate to enable rollout without breaking existing behavior). The following items from early -feedback are already implemented: - -- Bottom pane positioning is pegged high with an empty transcript and moves down as the transcript - fills (including on resume). -- Wheel-based transcript scrolling uses the stream-based normalization model derived from scroll - probe data (see `codex-rs/tui2/docs/scroll_input_model.md`). -- While a selection is active, streaming stops “follow latest output” so the selection remains - stable, and follow mode resumes after the selection is cleared. -- Copy operates on the full selection range (including offscreen lines), using the same wrapping as - on-screen rendering. -- Copy selection uses `Ctrl+Shift+C` (VS Code uses `Ctrl+Y` because `Ctrl+Shift+C` is unavailable in - the terminal) and shows an on-screen “copy” affordance near the selection. - -### 10.2 Roadmap (prioritized) - -This section captures a prioritized list of improvements we want to add to TUI2 based on early -feedback, with the goal of making scrolling/selection/copy feel as close to “native terminal” (and -Vim) behavior as we can while still owning the viewport. - -**P0 — must-have (usability/correctness):** - -- **Scrolling behavior.** Default to a classic multi-line wheel tick (3 lines, configurable) with - acceleration/velocity for faster navigation, and ensure we stop scrolling when the user stops - input (avoid redraw/event-loop backlog that makes scrolling feel “janky”). -- **Mouse event bounds.** Ignore mouse events outside the transcript region so clicks in the - composer/footer don’t start or mutate transcript selection state. -- **Copy fidelity.** Preserve meaningful indentation (especially code blocks), treat soft-wrapped - prose as a single logical line when copying, and copy markdown _source_ (including backticks and - heading markers) even if we render it differently. - -**P1 — should-have (UX polish and power user workflows):** - -- **Streaming wrapping polish.** Ensure all streaming paths use display-time wrapping only, and add - tests that cover resizing after streaming has started. -- **Selection semantics.** Define and implement selection behavior across multi-step output (and - whether step boundaries should be copy boundaries), while continuing to exclude the left gutter - from copied text. -- **Auto-scroll during drag.** While dragging a selection, auto-scroll when the cursor is at/near the - top or bottom of the transcript viewport to allow selecting beyond the visible window. -- **Width-aware selection.** Ensure selection highlighting and copy reconstruction handle wide glyphs - correctly (emoji, CJK), matching terminal display width rather than raw character count. -- **Multi-click selection.** Support double/triple/quad click selection (word/line/paragraph), - implemented on top of the transcript/viewport model rather than terminal buffer coordinates. -- **Find in transcript.** Add text search over the transcript (and consider integrating match - markers with any future scroll indicator work). -- **Cross-terminal behavior checks.** Validate copy/selection behavior across common terminals (incl. - terminal-provided “override selection” modes like holding Shift) and document the tradeoffs. - -**P2 — nice-to-have (polish, configuration, and interactivity):** - -- **Suspend printing.** Decide whether printing history on suspend is desirable at all (it is not - implemented yet). If we keep it, finalize the config shape/defaults, wire it through TUI startup, - and document it in the appropriate config docs. -- **Terminal integration.** Consider guiding (or optionally managing) terminal-emulator-specific - settings that affect TUI behavior (for example iTerm’s clipboard opt-in prompts or Ghostty - keybinding quirks), so the “works well out of the box” path is consistent across terminals. -- **Interactive cells (unlocked by transcript ownership).** Because transcript entries are structured - objects (not dead text in terminal scrollback), we can attach metadata to rendered regions and map - mouse/keys back to the underlying cell reliably across resizes and reflow. Examples: - - **Drill into a specific tool/command output.** Click (or press Enter) on a tool call / command - cell to open a focused overlay that shows the command, exit status, timing, and stdout/stderr as - separate sections, with dedicated “copy output” actions. This enables copying _just_ one command’s - output even when multiple commands are interleaved in a turn. - - **Copy an entire cell or entire turn.** Provide an action to copy a whole logical unit (one cell, - or “user prompt + assistant response”), without gutters and with well-defined boundaries. This is - hard to do with raw selection because step boundaries and padding aren’t reliably expressible in - terminal coordinates once the viewport moves or reflows. - - **Expand/collapse structured subregions with source-aware copy.** Tool calls, diffs, and - markdown can render in a compact form by default and expand in place. Copy actions can choose - between “copy rendered view” and “copy source” (e.g. raw markdown, raw JSON arguments, raw diff), - since we retain the original source alongside the rendered lines. - - **Cell-scoped actions.** Actions like “copy command”, “yank into composer”, “retry tool call”, or - “open related view” (diff/pager) can be offered per cell and behave deterministically, because the - UI can address cells by stable IDs rather than by fragile screen coordinates. -- **Additional affordances.** Consider an ephemeral scrollbar and/or a more explicit “selecting…” - status if footer hints aren’t sufficient. -- **UX capture.** Maintain short “golden path” clips showing scrolling (mouse + keys), selection and - copy, streaming under resize, and suspend/resume + exit printing. - -### 10.3 Open questions - -This section collects design questions that follow naturally from the current model and are worth -explicit discussion before we commit to further UI changes. - -- **“Scroll mode” vs “live follow” UI.** - - - We already distinguish “scrolled away from bottom” vs “following the latest output” in the - footer and scroll state. Do we need a more explicit “scroll mode vs live mode” affordance (e.g., - a dedicated indicator or toggle), or is the current behavior sufficient and adding more chrome - would be noise? - -- **Ephemeral scroll indicator.** - - - For long sessions, a more visible sense of “where am I?” could help. One option is a minimalist - scrollbar that appears while the user is actively scrolling and fades out when idle. A full - “mini‑map” is probably too heavy for a TUI given the limited vertical space, but we could - imagine adding simple markers along the scrollbar to show where prior prompts occurred, or - where text search matches are, without trying to render a full preview of the buffer. - -- **Selection affordances.** - - - Today, the primary hint that selection is active is the reversed text plus the on-screen “copy” - affordance (`Ctrl+Shift+C`) and the footer hint. Do we want an explicit “Selecting… (Esc to - cancel)” status while a drag is in progress, or would that be redundant/clutter for most users? - -- **Suspend banners in scrollback.** - - - When printing history on suspend, should we also emit a small banner such as - `--- codex suspended; history up to here ---` to make those boundaries obvious in scrollback? - This would slightly increase noise but could make multi‑suspend sessions easier to read. - -- **Configuring suspend printing behavior.** - - - The design already assumes that suspend‑time printing can be gated by config. Questions to - resolve: - - Should printing on suspend be on or off by default? - - Should we support multiple modes (e.g., “off”, “print all new cells”, “print streaming cell - tail only”) or keep it binary? - -- **Streaming duplication at the edges.** - - If we later choose to always re‑emit the “currently streaming” message when printing on suspend, - we would intentionally allow a small amount of duplication at the boundary of that message (for - example, its last line appearing twice across suspends). Is that acceptable if it improves the - readability of long streaming answers in scrollback, and should the ability to disable - suspend‑time printing be our escape hatch for users who care about exact de‑duplication?\*\*\* - ---- diff --git a/codex-rs/tui2/frames/blocks/frame_1.txt b/codex-rs/tui2/frames/blocks/frame_1.txt deleted file mode 100644 index 8c3263f518..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▒██▒▒██▒ - ▒▒█▓█▒█▓█▒▒░░▒▒ ▒ █▒ - █░█░███ ▒░ ░ █░ ░▒░░░█ - ▓█▒▒████▒ ▓█░▓░█ - ▒▒▓▓█▒░▒░▒▒ ▓░▒▒█ - ░█ █░ ░█▓▓░░█ █▓▒░░█ - █▒ ▓█ █▒░█▓ ░▒ ░▓░ - ░░▒░░ █▓▓░▓░█ ░░ - ░▒░█░ ▓░░▒▒░ ▓░██████▒██ ▒ ░ - ▒░▓█ ▒▓█░ ▓█ ░ ░▒▒▒▓▓███░▓█▓█░ - ▒▒▒ ▒ ▒▒█▓▓░ ░▒████ ▒█ ▓█▓▒▓ - █▒█ █ ░ ██▓█▒░ - ▒▒█░▒█▒ ▒▒▒█░▒█ - ▒██▒▒ ██▓▓▒▓▓▓▒██▒█░█ - ░█ █░░░▒▒▒█▒▓██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_10.txt b/codex-rs/tui2/frames/blocks/frame_10.txt deleted file mode 100644 index a6fbbf1a4b..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒████▒██▒ - ██░███▒░▓▒██ - ▒▒█░░▓░░▓░█▒██ - ░▒▒▓▒░▓▒▓▒███▒▒█ - ▓ ▓░░ ░▒ ██▓▒▓░▓ - ░░ █░█░▓▓▒ ░▒ ░ - ▒ ░█ █░░░░█ ░▓█ - ░░▒█▓█░░▓▒░▓▒░░ - ░▒ ▒▒░▓░░█▒█▓░░ - ░ █░▒█░▒▓▒█▒▒▒░█░ - █ ░░░░░ ▒█ ▒░░ - ▒░██▒██ ▒░ █▓▓ - ░█ ░░░░██▓█▓░▓░ - ▓░██▓░█▓▒ ▓▓█ - ██ ▒█▒▒█▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_11.txt b/codex-rs/tui2/frames/blocks/frame_11.txt deleted file mode 100644 index 88e3dfa7c5..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ███████▒ - ▓ ▓░░░▒▒█ - ▓ ▒▒░░▓▒█▓▒█ - ░▒▒░░▒▓█▒▒▓▓ - ▒ ▓▓▒░█▒█▓▒░░█ - ░█░░░█▒▓▓░▒▓░░ - ██ █░░░░░░▒░▒▒ - ░ ░░▓░░▒▓ ░ ░ - ▓ █░▓░░█▓█░▒░ - ██ ▒░▓▒█ ▓░▒░▒ - █░▓ ░░░░▒▓░▒▒░ - ▒▒▓▓░▒█▓██▓░░ - ▒ █░▒▒▒▒░▓ - ▒█ █░░█▒▓█░ - ▒▒ ███▒█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_12.txt b/codex-rs/tui2/frames/blocks/frame_12.txt deleted file mode 100644 index c6c0ef3e87..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █████▓ - █▒░▒▓░█▒ - ░▓▒██ - ▓█░░░▒▒ ░ - ░ █░░░░▓▓░ - ░█▓▓█▒ ▒░ - ░ ░▓▒░░▒ - ░ ▓█▒░░ - ██ ░▓░░█░░ - ░ ▓░█▓█▒ - ░▓ ░ ▒██▓ - █ █░ ▒█░ - ▓ ██░██▒░ - █▒▓ █░▒░░ - ▒ █░▒▓▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_13.txt b/codex-rs/tui2/frames/blocks/frame_13.txt deleted file mode 100644 index 7a090e51e3..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓████ - ░▒▒░░ - ░░▒░ - ░██░▒ - █ ░░ - ▓▓░░ - █ ░░ - █ ░ - ▓█ ▒░▓ - ░ █▒░ - █░▓▓ ░░ - ░▒▒▒░ - ░██░▒ - █▒▒░▒ - █ ▓ ▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_14.txt b/codex-rs/tui2/frames/blocks/frame_14.txt deleted file mode 100644 index f5e74d12b7..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ████▓ - █▓▒▒▓▒ - ░▒░░▓ ░ - ░░▓░ ▒░█ - ░░░▒ ░ - ░█░░ █░ - ░░░░ ▓ █ - ░░▒░░ ▒ - ░░░░ - ▒▓▓ ▓▓ - ▒░ █▓█░ - ░█░░▒▒▒░ - ▓ ░▒▒▒░ - ░▒▓█▒▒▓ - ▒█ █▒▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_15.txt b/codex-rs/tui2/frames/blocks/frame_15.txt deleted file mode 100644 index f04599ea27..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █████░▒ - ░█▒░░▒▓██ - ▓▓░█▒▒░ █░ - ░▓░ ▓▓█▓▒▒░ - ░░▒ ▒▒░░▓ ▒░ - ▒░░▓░░▓▓░ - ░░ ░░░░░░█░ - ░░▓░░█░░░ █▓░ - ░░████░░░▒▓▓░ - ░▒░▓▓░▒░█▓ ▓░ - ░▓░░░░▒░ ░ ▓ - ░██▓▒░░▒▓ ▒ - █░▒█ ▓▓▓░ ▓░ - ░▒░░▒▒▓█▒▓ - ▒▒█▒▒▒▒▓ - ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_16.txt b/codex-rs/tui2/frames/blocks/frame_16.txt deleted file mode 100644 index 1eb080286e..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█ ███░▒ - ▓▒░░█░░▒░▒▒ - ░▓▓ ▒▓▒▒░░ █▒ - ▓▓▓ ▓█▒▒░▒░░██░ - ░░▓▒▓██▒░░█▓░░▒ - ░░░█░█ ░▒▒ ░ ░▓░ - ▒▒░ ▓░█░░░░▓█ █ ░ - ░▓▓ ░░░░▓░░░ ▓ ░░ - ▒▒░░░█░▓▒░░ ██ ▓ - █ ▒▒█▒▒▒█░▓▒░ █▒░ - ░░░█ ▓█▒░▓ ▓▓░░░ - ░░█ ░░ ░▓▓█ ▓ - ▒░█ ░ ▓█▓▒█░ - ▒░░ ▒█░▓▓█▒░ - █▓▓▒▒▓▒▒▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_17.txt b/codex-rs/tui2/frames/blocks/frame_17.txt deleted file mode 100644 index dd5f5c8da5..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █▒███▓▓░█▒ - ▒▓██░░░█▒█░█ ▒█ - ██▒▓▒▒▒░██ ░░░▒ ▒ - ▓░▓▒▓░ ▒░ █░▓▒░░░▒▒ - ░▓▒ ░ ░ ▓▒▒▒▓▓ █ - ░▒██▓░ █▓▓░ ▓█▒▓░▓▓ - █ ▓▓░ █▓▓░▒ █ ░░▓▒░ - ▓ ▒░ ▓▓░░▓░█░░▒▓█ - █▓█▓▒▒▒█░▒▒░▒▒▓▒░░░ ░ - ░ ▒▓▒▒░▓█▒▓░░▒ ▒███▒ - ▒▒▒▓ ████▒▒░█▓▓▒ ▒█ - ▒░░▒█ ░▓░░░ ▓ - ▒▒▒ █▒▒ ███▓▒▒▓ - █ ░██▒▒█░▒▓█▓░█ - ░█▓▓▒██░█▒██ - ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_18.txt b/codex-rs/tui2/frames/blocks/frame_18.txt deleted file mode 100644 index a6c93e6c01..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒▒█▒▒█▓░█▒ - ▒█ ▒▓███░▒▒█ █▓▓▒ - ▒▓▓░█ █▒ █ ▓▒ █▓▓▒ █ - █░░█▓█▒ █ █▒░▒▓▒░▒▓▒▒▒█ - ▒▒▓▓ ▓░ ▒ █▒▒▓░▓░▒▒▓▒▒▒ - ▓▒░ ██░▓▒▒▒▓███░█▓▓▒▓░▓░ - ░░▒▓▓ █▓█▓░ ▒▓ █░▒░▒█ - ▒▓░░ ▒▒ ░░▓▒ ░▓░ - ▒ █▒▒▒▓▒▓█░░█░█▓▒█ ░█░░ - ▒▒▒░█▒█ ░░▓▒▒▒▒░░░▒▓░░▒ █ - ░▓░▒░ █████░ ▒▒▒▓░▓█▓░▓░ - ▒▒ █▒█ ░░█ ▓█▒█ - ▒▒██▒▒▓ ▒█▒▒▓▒█░ - █░▓████▒▒▒▒██▒▓▒██ - ░░▒▓▒▒█▓█ ▓█ - ░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_19.txt b/codex-rs/tui2/frames/blocks/frame_19.txt deleted file mode 100644 index 73341b5d58..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒▒▒█░█▒▒░▓▒ - ▒█░░░▒▓▒▒▒▒█▒█░███ - ██▓▓▓ ░██░ ░█▓█░█▓▒ - ▓▓░██▒░ ▒▒▒██▒░██ - ░░▓░▓░ █░▒ ▓ ░▒ ░▒█ - ░▒▓██ ▒░█░▓ ▓▓ █▓█░ - ▒▒░░█ ▓█▒▓░██░ ▓▓▓█░ - ░░░░ ░▓ ▒░ █ ░ ░░░ - ░█░▒█▒▓▓▒▒▒░░░░██▓█░▓ ▒ ░░ - ▒▓▓█░▒█▓▒██▒█░█ ▒▒ ▓▒▒▒█▓▓░▒ - █▒ ▓█░ ██ ▒▒▒▓░▓▓ ▓▓█ - ▒▒▒█▒▒ ░▓▓▒▓▓█ - █ ▒▒░░██ █▓▒▓▓░▓░ - █ ▓░█▓░█▒▒▒▓▓█ ▓█░█ - ░▓▒▓▓█▒█▓▒█▓▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_2.txt b/codex-rs/tui2/frames/blocks/frame_2.txt deleted file mode 100644 index 1c7578c970..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▒█▒▒▒██▒ - ▒██▓█▓█░░░▒░░▒▒█░██▒ - █░█░▒██░█░░ ░ █▒█▓░░▓░█ - ▒░▓▒▓████▒ ▓█▒░▓░█ - █▒ ▓█▒░▒▒▒▒▒ ▒█░▒░█ - █▓█ ░ ░█▒█▓▒█ ▒▒░█░ - █░██░ ▒▓░▓░▒░█ ▓ ░ ░ - ░ ▒░ █░█░░▓█ ░█▓▓░ - █ ▒░ ▓░▒▒▒░ ▓░█████████░▒░░█ - ▒▒█░ ▓░░█ ▓█ ░▒▒▒▒▒▒▓▓▒▒░█▓ ░ - ▒▒▒ █ █▒▓▓░█ ░ ███████ ░██░░ - █▒▒▓▓█ ░ ██▓▓██ - ▓▒▒▒░██ █▒▒█ ▒░ - ░░▒▓▒▒ ██▓▓▒▓▓▓▒█░▒░░█ - ░████░░▒▒▒▒░▓▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_20.txt b/codex-rs/tui2/frames/blocks/frame_20.txt deleted file mode 100644 index 3e0c5f0d9c..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒░░▒█▒█▒▒ - █▓▒ ▓█▒█▒▒▒░░▒▒█▒██ - ██ ▒██ ░█ ░ ▒ ▒██░█▒ - ▒░ ▒█░█ ▒██░▒▓█▒▒ - ▒░ █░█ ▒▓ ▒░░▒█▒░░▒ - ▓░█░█ ███▓░ ▓ █▒░░▒ - ▓░▓█░ ██ ▓██▒ █▒░▓ - ░▒▒▓░ ▓▓░ █ ░░ ░ - ░▓░░▓█▒▓▒▒▒▒▒▒▒██▓▒▒▒▒█ ▓ ░▒ - █░▒░▒ ▓░░▒▒▒▒░▒ █▒▒ ░▒▒ █▓ ░░ - ▒█▒▒█ █ ▒█▒░░█░ ▓▒ - █ ▒█▓█ ▒▓█▓░▓ - ▒▒▒██░▒ █▓█░▓██ - ▒█▓▓ ░█▒▓▓█▓ ░ ░█▓██ - ░██░▒ ▒▒▒▒▒░█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_21.txt b/codex-rs/tui2/frames/blocks/frame_21.txt deleted file mode 100644 index 971877651f..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒█▒▒█▒██▒▒ - ███░░▒▒█░▒░█▓▒░▓██▒ - ▓█▒▒██▒ ░ ░▒░██▒░██ - ██░▓ █ ▒█▓██▓██ - ▓█▓█░ █░▓▒▒ ▒▒▒▒█ - ▓ ▓░ ███▒▓▓ ▒▒▒█ - ░█░░ ▒ ▓░█▓█ ▒▓▒ - ░▒ ▒▓ ░█ ░ ░ - ░ ░ ██▓▓▓▓▓███ ▒░█ ░█ ▓▓ ░ - ░ ░▒ ░▒ ▒█░ ▒ ░█░█ ▓ ▓▓ - ▓ ▓ ░░ █░ ██▒█▓ ▓░ █ - ██ ▓▓▒ ▒█ ▓ - █▒ ▒▓▒ ▒▓▓██ █░ - █▒▒ █ ██▓░░▓▓▒█ ▓░ - ███▓█▒▒▒▒█▒▓██░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_22.txt b/codex-rs/tui2/frames/blocks/frame_22.txt deleted file mode 100644 index 2713fd669e..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▒█▒▒█▒██▒ - ▒█▓█░▓▒▓░▓▒░░▓░█▓██▒ - █▓█▓░▒██░ ░ █▒███▒▒██ - ▓█░██ ██░░░▒█▒ - ▒░░▓█ █▒▓░▒░▓▓▓█░ - ▒░█▓░ █░▓░▓▒▓░ ▒░▒▒░ - ░██▒▓ ░█░▒█▓█ ░░▓░ - ░░▒░░ ░▒░░▒▒ ░▒░ ░ - ░░█ █ █░▒▒▓▓▓▒██▒▒█░▒ ▒█ ▒░▓ - ▒░▒ █▒▒▒█ ▓█ ░▓▓░ ▒█▓▒ ░██ ▓▒▒ - ▒▒▒▒░ ██ ░ ░▓██▒▓▓▓ █░ - ▒█▒▒▒█ ▒██ ░██ - █ █▓ ██▒ ▒▓██ █▒▓ - █▓███ █░▓▒█▓▓▓▒█ ███ - ░ ░▒▓▒▒▒▓▒▒▓▒█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_23.txt b/codex-rs/tui2/frames/blocks/frame_23.txt deleted file mode 100644 index 39a6c55644..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▒▒████▒█▒▒ - ▒▒░█░▒▒█▒▒▒█░▒░█░█▒ - █ █░██▓█░ ░▓█░▒▓░░█ - ▓▓░█▓▓░ ▒▓▓▒░░▓▒ - ▓▓░░▓█ █▓████▓█▒░▒ - █▒░ ▓░ ▒█████▓██░░▒░█ - ░░░ ░ ▓▓▓▓ ▒░░ ░██ - ░▓░ ░ ░ ░█▒▒█ ░ █▓░ - ▒ ▒ ░█░▓▒▒▒▒▒▓▒░▒█░▒ ▒▒ ░ ░░░ - ░▒▒▒░ ▒ ▓░▒ ▒░▒▒█░ ▒▒░ - ▓█░ ░ ░ █░▓▓▒░▒▓▒▓░ - █░░▒░▓ █▓░▒▒▓░ - ▒ ░██▓▒▒ ▒▓ ▓█▓█▓ - ▒▒▒█▓██▒░▒▒▒██ ▓▒██░ - ░ █▒▒░▒▒█▒▒██░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_24.txt b/codex-rs/tui2/frames/blocks/frame_24.txt deleted file mode 100644 index 90ccc262f0..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒░▒▓███▒▒█▒ - █ ▒▓ ░▒▒░▒▒██▒██ - █ █▓▒▓█ ░ ▓░▓█░███ ▒ - ██▓▓█▓░▒█▒░░▓░ ▒█▒░▒▒█ - █ ▓▓▒▓█ ░ ▓▒▒░░░▒░██ - ░█▒█▒░ ███▓ ▓░▓ ▓ ▒ - ░ ░░ █▓▒█▓ ▓▒▒░▒▒░▒ - ░ ▒░░ ░█▒▓▒▒░░▒▓▓░░░ - ░▓ ░▓▓▓▓██░░░██▒██▒░ ░ ░░ - ▒ ▓ █░▓██▓▓██░▓▒▒██░ ░█░ - ▒ █▒░▒█ ░ ▒█▓█▒░▒▓█░ - ▒ ▒██▒ ░ ▓▓▓ - ▒▓█▒░░▓ ▒▒ ▒▓▓▒█ - ▓▓██▒▒ ░░▓▒▒▓░▒▒▓░ - █▓▒██▓▒▒▒▒▒██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_25.txt b/codex-rs/tui2/frames/blocks/frame_25.txt deleted file mode 100644 index d8fd5b45a8..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒█▒█▓████▒ - █ ███░▒▓▒░█░░█ - ▓░▓▓██ ▓░█▒▒▒░░░▒ - ░██░ ▓ ▒░ ▒░██▒▓ - █▒▒▒█▓█▒▓▓▒░ ░▓▓▒▓█ - ▒█░░░▒██▓▒░▓ ▓░█░▓▓░█ - ░▓░█░ ░▒▒▓▒▒▓░▒▓▒ ░▒░ - ░░░▓░▓ ░▒▒▒▓░▒▒░▒░░▒ - ▒█▒░ ░▒▒▒▒▒▒█░░▒▒░██░▒ - ▓▓ ░▓░█░▒░░▓█▒░▒█▒▓▒░ - ▒░█▓▒░░ ██▓░▒░▓░░ - ░▒ ░▓█▓▒▓██▓▒▓█▓▓░▓ - ▒░▒░▒▒▒█▓▓█▒▓▒░░▓ - ▒▓▓▒▒▒█▒░██ █░█ - ░█ █▒██▒█░█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_26.txt b/codex-rs/tui2/frames/blocks/frame_26.txt deleted file mode 100644 index a4734b4486..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓███ ██ - ▓█░▓▓▒░█▓░█ - ▓█ ░▓▒░▒ ▒█ - ▓█ █░░░▒░░▒█▓▒ - ░▒█▒░▓░ █▒▓▓░▒▓ - ▒ ░▓▓▓ █▒▒ ▒▒▓ - ░ ██▒░░▓░░▓▓ █ - ▓▓ ▒░░░▒▒▒░░▓░░ - ░ ▓▒█▓█░█▒▒▓▒░░ - ▓▒░▓█░▒▒██▒▒█░ - ░░ ▓░█ ▒█▓░█▒░░ - ▒▒░░▓▒ ▓▓ ░░░ - █ █░▒ ▒░▓░▓█ - ░ █▒▒ █▒██▓ - ▒▓▓▒█░▒▒█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_27.txt b/codex-rs/tui2/frames/blocks/frame_27.txt deleted file mode 100644 index b99e90e6d4..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓█████ - ░▓▓▓░▓▒ - ▓█░ █░▓█░ - ░░░▒░░▓░░ - ░ ░░▒▓█▒ - ░▒▓▒ ░░░░░ - ▒ ░░▒█░░ - ░ ░░░░▒ ░░ - ░▓ ▓ ░█░░░░ - █▒ ▓ ▒░▒█░░ - ░▓ ▒▒███▓█ - ░░██░░▒▓░ - ░▒▒█▒█▓░▒ - ▒▒▒░▒▒▓▓ - █▒ ▒▒▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_28.txt b/codex-rs/tui2/frames/blocks/frame_28.txt deleted file mode 100644 index de6db173b4..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓██▓ - ░█▒░░ - ▒ ▓░░ - ░▓░█░ - ░ ░░ - ░ ▓ ░ - ▒░░ ▒░ - ░▓ ░ - ▓▒ ▒░ - ░░▓▓░░ - ░ ▒░ - ░▒█▒░ - ░▒█░░ - █▒▒▓░ - ░ ▓█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_29.txt b/codex-rs/tui2/frames/blocks/frame_29.txt deleted file mode 100644 index d7b871c9c3..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ██████ - █░█▓ █▒ - ▒█░░ █ ░ - ░░░░▒▒█▓ - ▒ ░ ░ ░ - ░█░░░ ▒▒ - ░▒▒░░░ ▒ - ░░▒░░ - ░░░█░ ░ - ▒░▒░░ ░ - █░░▓░▒ ▒ - ░▓░░░ ▒░ - ░░░░░░▒░ - ░▒░█▓ ░█ - ░░█ ▓█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_3.txt b/codex-rs/tui2/frames/blocks/frame_3.txt deleted file mode 100644 index 833b2b3db2..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓██▒▒▒▒█▒ - ▒██▓▒░░█░ ▒▒░▓▒▒░██▒ - █▓▓█▓░█ ░ ░ ░ ███▓▒░█ - ▓█▓▒░▓██▒ ░▒█ ░░▒ - █▓█░▓▒▓░░█▒▒ ▒▒▒░░▒ - ▓░▒▒▓ ▓█░▒▓▒▒ ░ ▒▒░ - ▒█ ░ ██▒░▒ ░█ ▓█▓░█ - █▓░█░ █▓░ ▓▒░ ░▒░▒░ - ▓ █░ ▓░██░░█▓░▒██▒▒▒██▒░▒ ▓░ - █▒▓▒█ ▓▓█▓▓▓░ ░█░▒▒█ ▒▓█▓▒░░▒░░ - █▒░ ░ ░░██ ███ ███▓▓▓█▓ - ██░ ▒█ ░ ▓▒█▒▓▓ - ▒▒▓▓█▒█ ██▓▓ █░█ - ▒▒██▒██▒▒▓▒▓█▓▒█▓░▒█ - ░███▒▓░▒▒▒▒░▓▓▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_30.txt b/codex-rs/tui2/frames/blocks/frame_30.txt deleted file mode 100644 index 9c27cf67d0..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓ ████ - ▒▓▓░░▒██▒▒ - █▒░█▒▒░██▒ - ░░▒░▓░▒▒░▒ ▒█ - ▒█░░░▒░█░█ ░ - ░█░▒█ █░░░░▓░ - ▒▓░░░▒▒ ▒▓▒░ ▒░ - ░ ██▒░█░ ░▓ ░ - ░▒ ▒░▒░▒▓░█ ░ - ░░▒░▒▒░░ ██ ░ - ▒░░▓▒▒█░░░█░░ - ░█▓▓█▓█▒░░ ░ - ▒░▒░░▓█░░█░▓ - █▒██▒▒▓░█▓█ - ▒▓▓░▒▒▒▓█ - ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_31.txt b/codex-rs/tui2/frames/blocks/frame_31.txt deleted file mode 100644 index c787451d71..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▓████▒█ - ▒██▓██▒ █▒███ - █░▒▓▓█▒▒░▓ ░▒█▒ - █░▓█▒▒█▓▒█▒▒░▒░░▒ - ▒░░░░█▓█▒▒█ ▒░▓▒▒ - ▓░▒░░▒░█ ▒▓██▓▓░█ ░ - ▓░░ ░▒█░▒▓▒▓▓█░█░▓░ - ▒▒█ ░░ ░▒ ░▒ ░░▒▓░ - ░▒█▒░█▒░░░▓█░░░▒ ░ - ░░░▓▓░░▒▒▒▒▒░▒░░ █ - ▒█▒▓█░█ ▓███░▓░█░▒ - ░░░▒▒▒█ ▒▒█ ░ - ▓░█▒▒ █ ▓ ░█░▓░ - ▓░▒░▓▒░░█░ █░░ - █ ▒░▒██▓▓▓█ - ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_32.txt b/codex-rs/tui2/frames/blocks/frame_32.txt deleted file mode 100644 index e5e7adf64d..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - █████▓▓░█▒ - ▓█░██░▒░░██░░█ - ▓▒█▒▒██▒▓▓░█░█▒███ - █▓▓░▒█░▓▓ ▓ █▒▒░██ █ - ▓▓░█░█▒██░▓ █░█░▒▓▒█▒█ - ▒▓▒▒█▒█░░▓░░█▒ ░█▓ █ - █░ ▓█░█▒░░██░█▒░▓▒▓▓░█▒ - ░░░█▒ ▒░░ ▓█░▓▓▒ ▒░ ░ - ▒░░▓▒ █▒░ ▒▒░███░░░▒░ ▒░ - █ ▒░░█▒█▒▒▒▒▒▒░░█░▓░▓▒ - █▒█░░▓ ░█ ███▒▓▓▓▓▓▓ - ▒█░▒▒▒ █▒░▓█░ - ███░░░█▒ ▒▓▒░▓ █ - ▒▓▒ ░█░▓▒█░▒█ ▒▓ - ░▓▒▒▒██▓█▒ - ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_33.txt b/codex-rs/tui2/frames/blocks/frame_33.txt deleted file mode 100644 index 31a607b29c..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▒█▒█▒░▓▒ - ▒██░░▒█▒░▓░▓░█░█▓ - ▒▓▒░████▒ ░ █▓░░█ █ - █▒▓░▓▒░█▒ █░░▒▒█ - ▒▓░▓░░░▓▒▒▒ ░█▒▒▒ - ▓▓█ ▒▒▒▒░▒█ ▓▒▓▒▒ - ░░█ ▒██░▒░▒ ░█░░ - █░██ ███▒▓▒█ ▒ ░█ - ░░░ ░ █░ ▓████▓▒▒█░░█▓▒░▒░ - ▒▓░█ ▓▓█▓░░░▒▒▒▒▒░░█▒▒▒░░▓ - ▒▒▒█ ░▓░▓ ▓ ███ ░░█▓▒░ - ▒█▒██ █ ▓▓▓▓▒▓ - █▒ ███▓█ ▒█░█▓█▒█ - ▒░ █▒█░█▓█▒ ▓█▒█░█ - ▒▒██▒▒▒▒██▓▓ - ░░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_34.txt b/codex-rs/tui2/frames/blocks/frame_34.txt deleted file mode 100644 index db99cb73d6..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒█▒████▒░█▒ - ▒███▓▒▓░ ░██▒██▓█▒▒ - ▒▓▓█░█ ▓░█░ ░▒▒▒█ ███ - █▓▒░█▒▓█▒ █░██▒▒ - ▓▓░▒▓▓░ ░ █ ▒▒█▒▒ - █▓▒░░▓ ▒▒ ░▒█▒ ▒█▒░▒ - ░█▒░▒ █▒▒█░▒▒ ░▓░▒ - ▒░▒ ▓ ░█▒░▓ ░ ▓ ▒▒ - ██▓▓ ▓▒▓▓ ▒▒▒██████░▒▒ ░▒░ - ░░▒█▓██▒ ▓▓█░░░▒░▓▒▒▒█▓▒░░░░▒ - ▓▒▒█ ░▒░█▒ ██░░░░▒ █▓█▒░█ - ▓█▒▓▒▒▒ ▓▓▓░▓█ - ▒█░░█▒▓█ ▒█▒ ▒▓█░ - ▓▒▓░ ░██▓██▒█▒█░██▓█ - ░▒▓▒▒▒▒▒▒▓▒█▒▒ - ░░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_35.txt b/codex-rs/tui2/frames/blocks/frame_35.txt deleted file mode 100644 index 814188563d..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒██▓▒███▒██▒ - ██▒█▓░███ ░█░▓ ░█▒▒ - ▒▓▓░▓██░▒█ ░ ░ █▒█▓ ░██ - █▓▓█▓█▓█▒ ██▒▒░▒ - ▓▓░░▓▓▒ ▒██ ░▒█░█ - ▓▓▓▓█░ █░▒ ▓▓█▒ ░▒▒░ - ▒ ▓▓ ▒▒ ██▒▓ ░▒▒▒ - ░░░▓ ▓▒▒▓▓█ ▓ ▓ - ▓ █▒ █░░▓▓ ▓░▒▒▒▓▒▒█░░ ░░▒█ - ░█▒▓█ ▓▓▓ ██▓░▓ ▒█▒▒▒▒▓ ░▓█ ░█ - ▓░▒██▓▒▒░▓▒░ ░ ▒▒▒▒█▒▒█▓▓▒█░ - ▓▒▒▓░ ▒▓█ █▒ - ▒▓░▒▓█▓█ █▓▓▒███ - ▒▒ ░█░▓▓░░█░▓▓█ ▒▓▓ - ▒░▓▒▒▒▓▒▒███ ▒ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_36.txt b/codex-rs/tui2/frames/blocks/frame_36.txt deleted file mode 100644 index cde83b56f4..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒████▒██▒ - ▒▒ ▒█▓▓▓█▒█▓██ ███▒ - █▒█▒███▓█ ░░ ░ █░██░██░█ - ▒░ ██▒▒▒▒ ██░▒ ░ - █▓▒▓▒█░▒░▒█▓ ▒▒▓█ - ▓ █▓░ █▒ ░▓█ ▒▒█ - ░ ▓ ░ ▒ ▒▒ ░▒░█ - ░░▒░ ▒▒ ▒▓▓ ▒░ ░ - ░█ ░ ▓▓ ██ ████▒█████▒ ░▒░░ - ▒█░▒ █░▒▒▓░▓ ░░▒▒▒▒▒▒▒░░ ▒▓█░ - █ █░▒ █▒█▓▒ ██▒▒▒▒▒ ░█ ▓ - ██ ▒▓▓ █▓░ ▓ - ▒▓░░█░█ ███ ▓█░ - ██▒ ██▒▒▓░▒█░▓ ▓ █▓██ - ░██▓░▒██▒██████ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_4.txt b/codex-rs/tui2/frames/blocks/frame_4.txt deleted file mode 100644 index 7ad27d16e7..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓█▒▒█▒██▒ - ▒▓ ██░▓ ░▒▒▓█▓░ ▓██▒ - ██▒░░░██ ░ ░▒░▒▒█░▓▒▒▒ - ▓▓░█░ ▓██ ░██▒█▒ - ▓░▓▒░▒░▒▓▒░█ ▒ ▒░█▒ - ▓░░▒░ █▒░░▓█▒ █ ▒▒░█ - ▒░▓░ ███▒█ ░█ █ ▓░ - ░▓▒ █░▓█▒░░ ░░░ - ▒░ ░▒ ▓░▓ ▒▓▓█░███▒▒▒▒██ ░░█ - ░▒▓ ░ █▓▓▓█▒░░▒▒░█▓▒█▓▓▒▓░▓▓ ░ - ░░▓█▒█▒▒█▒▓ ████████▒▓░░░░ - █░▒ ░▒░ █▒▓▓███ - ▒▒█▓▒ █▒ ▒▓▒██▓░▓ - ░░░▒▒██▒▓▓▒▓██▒██▒░█░ - █▒▒░▓░▒▒▒▒▒▓▓█░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_5.txt b/codex-rs/tui2/frames/blocks/frame_5.txt deleted file mode 100644 index 24f9843954..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▓█▒▒▒██▒ - ▒█ █▓█▓░░█░▒█▓▒░ ██ - █▒▓▒█░█ ░ ▒▒░█▒ ███ - █░▓░▓░▓▒█ ▓▒░░░░▒ - █▒▓█▓▒▒█░▒▒█ ░ ▒░▒░▒ - ░░░░▓ ▒▒░▒▓▓░▒ █▓░░ - ░▓░ █ ░▒▒░▒ ░█ ██░█░█ - ░▓░▒ █▒▒░▓▒░ █░▒░ - ░█░▒█ ▓▒░ █░█▒▒░█░▒▒▒██▒ ░▓░ - ▒▒░▒██▓██ ░ ▓▓▒▒▒█▒▓█▓░▓█░░ - ▒█░░█░█▒▒▓█░ ██ █░▓░▒▓ - ▒▒█▓▒▒ ░ ▓▒▓██▒ - ▒▓█▒░▒█▒ ▒▒████▓█ - ▒░█░███▒▓░▒▒██▒█▒░▓█ - ▒▓█▒█ ▒▒▒▓▒███░ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_6.txt b/codex-rs/tui2/frames/blocks/frame_6.txt deleted file mode 100644 index fe185a7573..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓▒▓▓█▒▒██▒▒ - █▒▓▓█░▒██░██▓▒███▒ - ███░░░█ ░ ░▓▒███▓▒▒ - ▓█░█░█▒▒█ ▒█░░░░█ - █▒░░░█▒▒██▒ ▓▒▒░▒█ - ▓▓▓░▓░▒█▓░▒▒░█ ▓▒▒▓░ - ▒ █░░ ▒▒░▓▒▒ ▒█░▒░ - ░ ░░░ ▒░▒░▓░░ ░█▒░░ - ▒▓░▓░ ▓█░░█▓▓█▒░█░▒▒██▒▓▒▓░ - ░░▒█▓▒▒▒▓█ ░▓▒██░░█▓▒▒▒░█░▒ - ▓ ░ ▓░░░▓▓ █ ██ ░▒▒▓░ - █ ▓ ▓█░ █▓▒▓▓░░ - ▓░▒▒███ ▒█▒▒▓███ - ░ ░██ █ ▓░▒▒████ ▓▓█ - ▒▓▓███▒▒▒░▒███ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_7.txt b/codex-rs/tui2/frames/blocks/frame_7.txt deleted file mode 100644 index 7441f97e96..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▓░▓██▒▒██▒ - ██░█▒░███▒▒▒▓ ░██ - █ █░░░█░ ░▒░░ █▓▒██ - ▒▒░░░░▓█ ▒░▒█░▓█ - ░█░█░░▒░▓▒█ ▓ █░░▒ - ░ ▓░░ ░█▒▓░▒ █▓░░░ - ░▒ ░ ▒▒░▒░▒░ ██▒░░ - ▒ ▓░░ ▒█▓░█░░ █ ░░░ - ▓ ░█ █ ▒▓░▒▓░░▓▓▒░░▒▓█▒░░ - ░██░░▒▓░░▓█░▓▒░░▒▒█▒█▓▒░▒░ - ▒ ▒▒▓█░█▒▓ ██████ ▒▓░░ - █▒ ▓▒▓▒░ █ ▓▓▓▓█ - █▓██▒▒▒▒ █▒░██▓██ - ▒▒█▒░█▒▓░▒▒▒██░██▓ - ░█ ░▓░▒▒█▒▓██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_8.txt b/codex-rs/tui2/frames/blocks/frame_8.txt deleted file mode 100644 index ea88b09538..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▒▒█▒▓██▒██▒ - █ █▓░░░█▒▒ ░ █ - ▒░▒█░▓▓█ █ ░▓░█▒█▒█ - ▒█▒█▓░██░ █ ▒▒░░▒ - █ ▓░▓█▒░▓▒ ▓█▒░░█ - ░██░▒▒▒▒▒░▒█ ▒█░░░ - ░█░░░ █▒▓▒░░░ ░▒░▓░█ - ▒█░░▓ ░█▒▓░██▓ ▓░▓░░ - ▒ ▒░░▒▒ ▓█▒░░▓█████▒░░░ - ▒█▓▒▒░ █░█░░▓░▒▒▒░░▒█ - ▓▓▒▒░▒░░░▓█▒█▒█ ▒█ ▓▒░ - ██ ░▒░░░ ▓█▓▓▓█ - █▒▒█▒▒▒▒ ▒▓▒▒░█▓█ - ▓▓█░██ ▓▓██▓▓▒█░░ - ░░▒██▒░▒██▓▒░ - ░░ \ No newline at end of file diff --git a/codex-rs/tui2/frames/blocks/frame_9.txt b/codex-rs/tui2/frames/blocks/frame_9.txt deleted file mode 100644 index 9066ba1bed..0000000000 --- a/codex-rs/tui2/frames/blocks/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▓▒▒█▓██▒█ - ▓█▒▓░░█ ▒ ▒▓▒▒ - ▓ █░░▓█▒▒▒▓ ▒▒░█ - ░░▓▓▒▒ ▒▒█░▒▒░██ - ▓█ ▓▒█ ░██ █▓██▓█░░ - ░ ░░░ ▒░▒▓▒▒ ░█░█░░░ - ░ ░█▒░██░▒▒█ ▓█▓ ░░░ - ░ ░▓▒█▒░░░▒▓▒▒▒░ ░░ - █░ ▓░ ░░░░█░░█░░░ - ░▒░░░▒█░▒░▒░░░░▒▒░░░ - ░▒▓▒▒░▓ ████░░ ▓▒░ - ▒░░░▒█░ █▓ ▒▓░░ - ▒█▒░▒▒ ▓▓▒▓░▓█ - ▒▓ ▒▒░█▓█▒▓▓█░░ - █▓▒ █▒▒░▓█▓ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_1.txt b/codex-rs/tui2/frames/codex/frame_1.txt deleted file mode 100644 index 63249f4242..0000000000 --- a/codex-rs/tui2/frames/codex/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoeddccddcoe - edoocecocedxxde ecce - oxcxccccee eccecxdxxxc - dceeccooe ocxdxo - eedocexeeee coxeeo - xc ce xcodxxo coexxo - cecoc cexcocxe xox - xxexe oooxdxc cex - xdxce dxxeexcoxcccccceco dc x - exdc edce oc xcxeeeodoooxoooox - eeece eeoooe eecccc eccoodeo - ceo co e ococex - eeoeece edecxecc - ecoee ccdddddodcceoxc - ecccxxxeeeoedccc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_10.txt b/codex-rs/tui2/frames/codex/frame_10.txt deleted file mode 100644 index fe5e51b984..0000000000 --- a/codex-rs/tui2/frames/codex/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eccccecce - ccecccexoeco - eeoxxoxxoxceoo - xeeoexdeoeocceeo - o dxxcxe cooeoxo - xe cxcxooe eecx - e xcccxxxxc xoo - c xxecocxxoeeoexx - c xe eexdxxcecdxx - x oxeoxeoeceeexce - o cxxxxxcc eocexe - eecoeocc exccooo - xc xxxxcodooxoe - deccoxcde ooc - co eceeodc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_11.txt b/codex-rs/tui2/frames/codex/frame_11.txt deleted file mode 100644 index 48e507a84a..0000000000 --- a/codex-rs/tui2/frames/codex/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occcccce - oc dxxxeeo - oceexxdecoeo - xeexxddoedoo - ecodexcecdexxo - xcexxceddxeoxx - cc oxxxxxxexde - x xxoxxeo xcx - o cxoxxcocxex - cc exodocoxexe - ceo xxxxdoxeex - eeooxecoccdxe - e cxeeeexdc - ec cxxoeoce - ee cccece - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_12.txt b/codex-rs/tui2/frames/codex/frame_12.txt deleted file mode 100644 index 29de69516a..0000000000 --- a/codex-rs/tui2/frames/codex/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ccccco - odeeoxoe - c xoeco - ocxxxddcx - x cxxxxoox - xcoocecexc - x xoexxe - x ocexxc - co xoxxcxx - x oxcdce - xo xcdcco - o cx eox - o ccxocex - ceocoxexe - e cxeoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_13.txt b/codex-rs/tui2/frames/codex/frame_13.txt deleted file mode 100644 index 67fe336a13..0000000000 --- a/codex-rs/tui2/frames/codex/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occco - xeexx - xeexc - xccxe - c xx - cdoxx - o xx - c cx - oc exo - xc cdx - ceoo xe - xeeex - xcoxe - ceexd - o ocd - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_14.txt b/codex-rs/tui2/frames/codex/frame_14.txt deleted file mode 100644 index f8d32cd6d1..0000000000 --- a/codex-rs/tui2/frames/codex/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ccccd - ooeeoe - xexxo x - xxoxcexo - xxxe x - xcxx cx - xxxx o c - xxexe e - xxxx c - ceoo do - exccooox - xcxxeeex - o cxddde - xeoceeo - ec cdo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_15.txt b/codex-rs/tui2/frames/codex/frame_15.txt deleted file mode 100644 index 2e14341237..0000000000 --- a/codex-rs/tui2/frames/codex/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - cccccxe - eodxxedco - ooxcdexccx - xoe ooooeex - xxdcdexxocex - exxoxxoox c - xx xxxxxxox - xxoxxcxxx cox - xxcoocxxxeodx - xexdoxexco ox - xoxxxxex e d - xccoexxeo d - cxeo oooe de - xexxeeoceo - eeceeeeo - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_16.txt b/codex-rs/tui2/frames/codex/frame_16.txt deleted file mode 100644 index c90ce92cb6..0000000000 --- a/codex-rs/tui2/frames/codex/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - edcccccxe - oexxcxxexde - xooceodexx ce - ooo dceexexxccx - xxdeoccdxxcoxee - xxxcxc xed x xox - eex oeoxxxxocco x - xod xexxoxxxcd ex - eexxxcxoexxccc o - cceeoddecxoex oex - xxxcccocexdcdoxxe - xxc xe eooo o - exc x oooeox - exxcecxoocex - cdoeddeedc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_17.txt b/codex-rs/tui2/frames/codex/frame_17.txt deleted file mode 100644 index e1f2bb6d96..0000000000 --- a/codex-rs/tui2/frames/codex/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - odcccddxoe - edccxxxcdcxoceo - oceoeddecocxxxece - oxoeoxcee cxdexxxde - xoe x xcoedeoo o - edcooe odox oodoxoo - c dox oooxe ccxxodx - ocdx ooxxoxoxxddc - oocoeddcxeexeedexxx x - xcedeexoceoxxe eccce - eeeoccccccceexcooe ec - exxec eoxxe d - eee cee ocooeeo - o xccdeceedcdxc - ecdoeocxcecc - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_18.txt b/codex-rs/tui2/frames/codex/frame_18.txt deleted file mode 100644 index be64251770..0000000000 --- a/codex-rs/tui2/frames/codex/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eddcddcdxoe - eccedoccxeeoccdde - eodxcccdcocoeccooe c - oxxcooecc ceeeodxedeeeo - eeoo ox ecceeoxoxeedeee - oex ooxoeeeoocoxcooeoeox - xxedo cocoxceoccxdxdo - ceoxx eecxxde xdxc - ecc oedddddcxxoxcoeo xcxe - eeexcec xxoeeeexxxedxee o - xoxeeccccccce eeeoxocoeoe - ee oeo eeccocec - eecceeo eceeoeoe - cxoccccdddecceoeoc - cxxeoeeooccdcc - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_19.txt b/codex-rs/tui2/frames/codex/frame_19.txt deleted file mode 100644 index 8904157121..0000000000 --- a/codex-rs/tui2/frames/codex/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eeddcxcddxoe - ecxxxeodddeceoxcoo - ocddocxcce ecdoecde - odxcoee eddcoexco - xxoeoe oxecocxe xeo - xeocc excxo oo cocx - edxxc oceoxcoe odocx - xxxx xdcexco x xxx - xcxeoddddddxxxxccdcxd e cxx - edooxdcoecceoeo ee deeeoooxe - cecocxcccccccc eeeoxoo ooc - eeecee eooeooc - c eexxco oddooxde - ccoxcoxceeddocc dcxc - cxoedoceooecoe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_2.txt b/codex-rs/tui2/frames/codex/frame_2.txt deleted file mode 100644 index a3c0663db4..0000000000 --- a/codex-rs/tui2/frames/codex/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoeddcdddcoe - ecoocdcxxxdxxdecxcce - oxcxeccxcee eccdcoxxdxo - exoeoccooe ooexoxo - oecocexeeeee eoxexo - cocce xcecoec eexcx - oxccx eoxdxexo ocxcx - xc ee oxcxxdc xcoox - cccdx dxeeexcoxccccccccoxexxc - edcx oxxc oc xdeeeeeooeexco x - eee c ceooxc ecccccccccxocxx - ceeooo e ocdooc - oeeexco odec exc - exedeecccdddddodceexxc - eccccxxeeeexdocc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_20.txt b/codex-rs/tui2/frames/codex/frame_20.txt deleted file mode 100644 index cea5393f75..0000000000 --- a/codex-rs/tui2/frames/codex/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eecdxxdcdoee - oddcdoeodddxxeececo - oocecccxcc ecececcxce - excecxc eocxeocee - ex oxc eo exxecexxe - oeoxc cccdxco cexxe - dxdcx oc occe oexo - xeeoe ccddxco xxcx - xoxxdoddddddddeocdeeeec o xe - cxexec oeeeeeexe ceecxde oo xx - eoeecccccccccc eodxxox oe - c ecoo eocoxo - eeecoxe odcedcc - eooocxceddodcxceoocc - eccxe deeeexccc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_21.txt b/codex-rs/tui2/frames/codex/frame_21.txt deleted file mode 100644 index efa6d610d9..0000000000 --- a/codex-rs/tui2/frames/codex/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eeodcddcdcoee - occeeeecxdxcdeeocce - dceeccece eexcceeco - ocxdcc eodcodco - oooce oxoee eeeeo - ocox occeoo eeeo - xcxe e oeooc edec - ee ed cxo x x - x x ocdddddccc exocxo do x - x xe xe eox ececxo ocoo - d co eeccc ce cceod oe o - cc dde ecc o - ce eoe eodcc oe - cde ccccdxxdddccc oe - cccdceeeeoedcce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_22.txt b/codex-rs/tui2/frames/codex/frame_22.txt deleted file mode 100644 index 91c9c2ecaa..0000000000 --- a/codex-rs/tui2/frames/codex/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocdcddcdcoe - ecocxoeoxoexxdxcocce - odcdxecce ecceccceeco - dcxccc ooxxxece - exxoc oeoxdxoodcx - excoe oxoxdeoe exedx - xcceo xcxecoc xxox - xxdxe xexxee xexcx - xxoco cxddddddcceecxe eo exdc - exd ceeeo oocxoox ecdecxoo oed - eeeex cccccccce edcceooocoe - eceeeo ecocxoc - cccd cce eococceo - cdccoccxddcddodccccoc - cxcxedeeeodeodce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_23.txt b/codex-rs/tui2/frames/codex/frame_23.txt deleted file mode 100644 index 5b5f1be139..0000000000 --- a/codex-rs/tui2/frames/codex/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocedccccdcee - edxcxeeoeddoxexcxce - occxcodce cxdcxedxxo - odxcdoe eddexxde - ooxeoc ooooccocexe - oexcoe ecccccoccxxexo - exxcx odoo exe c xcc - xox x xcxoeeo x cox - ece xcxddddddddxecxecee x xxx - xeeexcdc oee exeeox eex - ocx x eccccccc ceoddxeoeoe - oxxexo ooxeeoe - e xocoee eocdcoco - edecdccexddecccoecce - cx cdexeeceecce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_24.txt b/codex-rs/tui2/frames/codex/frame_24.txt deleted file mode 100644 index c0269d8eda..0000000000 --- a/codex-rs/tui2/frames/codex/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - exedcccddoe - oceocxeexddcoecc - occdeoccx oedcxcco e - ocooooxdoeexoe ecexeec - o ooeoo eccoeexeeexoc - xoecee cooo oxd oce - x xx ooeoocoeexeexe - x exx xodoeexxeooexx - xo xddddccxxxccecoex x xx - e o cxoooddooxoeeccx xcx - e cexeccccccce eoocexdooe - e eoce x codo - eoceexo edceodec - oocoeecxxddddxeeoe - cdeccdeeeddcc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_25.txt b/codex-rs/tui2/frames/codex/frame_25.txt deleted file mode 100644 index 5b040665d0..0000000000 --- a/codex-rs/tui2/frames/codex/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ecdcdcccce - o coceedexcxxo - oxoooocoxcedexxxe - xccx o dx cexoceo - oeeeoocedoexc xooeoc - eoxxxeccoexd oxoxooxo - xoxcx xeeoeeoxeoecxdx - xxxoxoc xedeoxeexdxxe - ecexcxeeddddcxxeexccxe - oocxoxoxexxdcexecdoex - excoexecccccccoxexoxe - xecxdcdeoocdeooooxo - eeexeeecdooeoexxo - eodeeecdxcc cxc - xoccecoecxc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_26.txt b/codex-rs/tui2/frames/codex/frame_26.txt deleted file mode 100644 index 1592c09e8c..0000000000 --- a/codex-rs/tui2/frames/codex/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - edccccco - ocxdoexcdxo - occcxdexecceo - dccoxxxexxecoe - xeoexoxcceodxed - e cxodocceeceeo - x ccdxxoxxddcc - oo exxxeedxxoxx - x oecdcxcddoexx - oexooxeeoceecx - xecoxcceooecexx - eexxoe oocxxe - c cxe eeoxoo - xcceecceccd - eodecxeec - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_27.txt b/codex-rs/tui2/frames/codex/frame_27.txt deleted file mode 100644 index 5279157c04..0000000000 --- a/codex-rs/tui2/frames/codex/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dcccco - xddoxoe - dce cxocx - xxxexxdxx - x exeocd - xeoecexxxe - d cxxecxx - x exxxdcxx - xo o xcxxxx - cd ocexecxx - xo eecccoc - xxccxxeox - xddcdooxe - eeexedoo - cec eeo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_28.txt b/codex-rs/tui2/frames/codex/frame_28.txt deleted file mode 100644 index ea695865f4..0000000000 --- a/codex-rs/tui2/frames/codex/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occd - xcexe - d dxe - xoecx - x xx - x ocx - exx ex - xoccx - oe ex - xxodxx - x ex - xdcdx - xdcxx - ceeox - x ocx - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_29.txt b/codex-rs/tui2/frames/codex/frame_29.txt deleted file mode 100644 index 328d426a41..0000000000 --- a/codex-rs/tui2/frames/codex/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ccccco - oxco ce - eoxx ccx - xxxxeeoo - e xcx x - xoxxx ee - xeexxx e - xxdxx - xxxcx e - exdxx e - cxxoxe d - xoxxx ex - xxxxexex - xdxcocxc - xxc oo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_3.txt b/codex-rs/tui2/frames/codex/frame_3.txt deleted file mode 100644 index 3e9206577a..0000000000 --- a/codex-rs/tui2/frames/codex/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoddccddddoe - ecooexxcxcddxdeexcce - odocdxccce ecx cccoexo - ocoexdoce edc xxe - cocxoeoxxcee eeexxe - oxeeo ooxedee x eex - dc x ccexecxo ocoxo - ooxox ooxcoex xexdx - occx dxccxxcoxdcceeeccexecdx - oedeo oocoddx xcxeeo doodeexexe - cex x cxxcoc cccccccccoooooo - ccx ec e oeceoo - deooceo ocdocoxc - decoecceddddoddcdeecc - ecccedxeeeexdoec - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_30.txt b/codex-rs/tui2/frames/codex/frame_30.txt deleted file mode 100644 index b9da98c5c3..0000000000 --- a/codex-rs/tui2/frames/codex/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - edcccco - eodxxeccde - ccexoeexcoe - xxexoxeexe eo - dcxxeexoxo x - xcxec cxxxxox - eoxxxee eoex de - cx ccdxoxcxo e - cxecexdxeoxo e - cxxexeexx co e - exxdeecxxxcxx - xcoooocexxc x - exexxocxxoxo - oeocdeoxooc - eooxeeedc - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_31.txt b/codex-rs/tui2/frames/codex/frame_31.txt deleted file mode 100644 index baef07474c..0000000000 --- a/codex-rs/tui2/frames/codex/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodccccdo - eccdcoeccecco - oxeooodeeocxece - oxoceecdeoeexexxe - exxxxcoceeocexoee - dxeexexccedcoooxocx - oxx xecxeododcxcxox - eeo xxcxe xeccxxeox - xeoexcexxxocxxxe x - cxxxooxxeeeeexexx c - eceocxo occceoxcxe - xxxeeeo edc x - dxcde o o xceoe - dxexoexeoxcoxe - ccdxeccoodc - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_32.txt b/codex-rs/tui2/frames/codex/frame_32.txt deleted file mode 100644 index c0997d9a14..0000000000 --- a/codex-rs/tui2/frames/codex/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - occccddxoe - dcxccxexxccxxo - oecdeocedoecxcecco - cooxeoedo o oeexco o - ooxoxceccxd ceoxeoeceo - eoeeoecxedxxce xco c - cxcdoecexxooxodeoeooxce - xxxoe cexxcocxdoecexcce - exxoe cexceexcccxxxdxcde - ccceexceceeeeeexxcxdxoe - oecxxo xccccccedooooo - eoxeee oexocx - cccxxxce eoexo o - eoecxcxddceecceo - xddeeococecc - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_33.txt b/codex-rs/tui2/frames/codex/frame_33.txt deleted file mode 100644 index cd8691c150..0000000000 --- a/codex-rs/tui2/frames/codex/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocdcdcdxoe - eccxxecdxdxoxcxco - eoexcccodce ccoxxcco - oeoxoexoe cxxeec - eoxoexxoeee xceee - ooo eeeeeeo oeoee - xxc eocxexe xcxx - cxoo occeodo ecxc - xxx x oe ocooodddcxxcoexex - eoxccodooexxeeeeexxceeexxo - edeo xoxo o ccccccc xxooee - ececoco oododo - ceccocdo ecxoocec - exccecxodcecdoecxc - cddcoeeeeccdoc - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_34.txt b/codex-rs/tui2/frames/codex/frame_34.txt deleted file mode 100644 index ef8eabf7dc..0000000000 --- a/codex-rs/tui2/frames/codex/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodccccdxoe - ecccoedxcxccdccdode - eoooxccdxce eeeeoccco - ooexceooe cxocee - ooxeoox xc o eecee - ooexeo eecxece eoexe - xcexe ceecxee xdxe - exdcd xcexocx o ee - ocooc oeooceddccccccxeec xee - xxeooooecoocxxxexoeeeooexxexe - oeeo xexce ccceeeee oooexc - ooeddee odoxoc - ecexcedo ecdceooe - oeoxcxcodocdcdceccdc - cxeddeeeeeddcde - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_35.txt b/codex-rs/tui2/frames/codex/frame_35.txt deleted file mode 100644 index 1c53d2373f..0000000000 --- a/codex-rs/tui2/frames/codex/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eocddcccdcoe - ocdcoxccccxcxdcxcde - eooxdccxecce eccdcocxco - ooocoodoe cceexe - ooxxooe ceco xecxo - dodoce cxecooce xeex - e oo ee cceo xeee - xxxd oeedoc o co - o oe oxxodcoxddededcxx xxdc - xoedc oodcccoxd eoeeeeocxoc xc - oeeocoeexoee eceeeeceecooeox - coeeox eoc oe - edeedodo odoeccc - ceecxcxodxxcxdocceodc - cexddeeoeecccce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_36.txt b/codex-rs/tui2/frames/codex/frame_36.txt deleted file mode 100644 index 4928a2a9d0..0000000000 --- a/codex-rs/tui2/frames/codex/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eecdccccdcoe - edccecodocecdcccccce - oeceoccoccee eccxccxocxo - exccceeee ccxecx - ooedecxeeeoo eeoc - o ooe ce cxoo ceec - x d e e cee xexo - xxex ee eoo ex x - xccx oo occcocceccccce xexx - ecxe oxeeoxo exeeeeeeexx eoce - c cxe cecoe ccceeeeec xoco - cocedo ooxco - eoxxcxo occcooe - coecccdedxecxdcocodcc - eccoxeooeooccccc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_4.txt b/codex-rs/tui2/frames/codex/frame_4.txt deleted file mode 100644 index a5ae50eeae..0000000000 --- a/codex-rs/tui2/frames/codex/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoddcddcdcoe - eocccxo xedocdxcocoe - ocdxxxccce eexeecxoeee - ooxoxcoco cxccece - oxoexdxedexo ecexce - oxxex cexxoce c edxo - cexde ccceccxo o cdx - xoe oxocexx xxx - ex xe dxoceoocxccceeeeoo xxc - xeo x oooooexedexooeodoedxoocx - exdceoeeoeo ccccccccceoxxxe - oxecxee oedoccc - eeode oe eoeocdxo - xxxdecceddddocdccexce - ceexdxeeeeedoce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_5.txt b/codex-rs/tui2/frames/codex/frame_5.txt deleted file mode 100644 index 47abf7a0af..0000000000 --- a/codex-rs/tui2/frames/codex/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodddcdddcoe - ecccocoxxcxdcdexcco - ceoecxcce cedxce oco - oxoxoxodo oexxxxe - oeocoeecxeeo e exexe - xxxxo eexedoxe coxx - xox c eeexecxo ccxcxo - xoxec oedxoex cxex - xoxec oexcoxcdexcxeeecoecxox - eexeoooccc xc ooedeodoooxocxe - eoxxoxoeeoce ccccccccoxdxeo - eecoee e oeocoe - eocexdce edcoccdc - excxoccedxdeocdcexdc - eocecceeeoeocce - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_6.txt b/codex-rs/tui2/frames/codex/frame_6.txt deleted file mode 100644 index ba04c52772..0000000000 --- a/codex-rs/tui2/frames/codex/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eodddcddccee - oedocxdccxccdeocce - oooxxxcc ecxodcccoee - ooxcxcedo ecxxxxo - cdxxxceecce oeexeo - dooxoeecdxeexo odeox - e cxx eexoee ecxex - x xxx exdxoxx xcexx - eoxox ocxxcdocexcxeecceoeox - xxeooeeedc xodcoxxodddexoxe - o x oxxxdoc cccccccceeeox - o d ooe odeooxe - oeeecco eceeococ - ecxcocc dxeeoccc ddc - eodccoeeeeeocc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_7.txt b/codex-rs/tui2/frames/codex/frame_7.txt deleted file mode 100644 index f7dd0de9b6..0000000000 --- a/codex-rs/tui2/frames/codex/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoxdccddcoe - ocecexcccdded eco - c oxxxce eexe coeco - eexxxxdc execxoo - ecxcxxexoeo o cxxe - x dxxc ecedxe ooxxx - eecx eexexex ccexx - ecoxx dcoxcxe c xxx - ocxc oceoxeoxxddexxddcexx - xocxxddxxocxoexxeeododexex - e deocxceo cccccccceoxx - ce oeoee ocoodoc - cdoceeee oexococc - eecexcedxeeeccxcco - cxccxdxeeoedcc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_8.txt b/codex-rs/tui2/frames/codex/frame_8.txt deleted file mode 100644 index e3f93702f7..0000000000 --- a/codex-rs/tui2/frames/codex/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eecedccdcoe - occoxxxcdd x cc - exdoxooc ocxoxceceo - ececoxocx c dexxe - c oxooexoe ocexxo - xcoxeeeeexec ecxxx - xcxxx cedexex xexoxo - eoxxo xceoxoco oeoxx - e exxee ocdxxococooexxx - ecodexcoxoxxdxdeexxdc - ooeexexxxocececceccoex - cocxexee oooooc - ceeceeee eoeexcoc - odcxoc ddccdodoxe - xxeccexeocode - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/codex/frame_9.txt b/codex-rs/tui2/frames/codex/frame_9.txt deleted file mode 100644 index 210e417d43..0000000000 --- a/codex-rs/tui2/frames/codex/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - odecoccdo - oceoxxccd eoee - o oxxoceedo eexo - c xxodde eeoxeexco - occdeccxco coccdcxx - x xxxcexedee xcxcxxx - e xcexccxeeocooo exx - x xoeoexxxeodeex xx - coxc oxcxxxxcxxoxxe - xexxxeoxexexxxxeexxx - c eeoeexocccccxxcoex - exxxeoe oo eoxe - ecexee odedxoc - eoceexcocdddcxe - coe ceexdcoc - \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_1.txt b/codex-rs/tui2/frames/default/frame_1.txt deleted file mode 100644 index 64a140d2b9..0000000000 --- a/codex-rs/tui2/frames/default/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _._:=++==+,_              -         _=,/*\+/+\=||=_ _"+_          -       ,|*|+**"^`   `"*`"~=~||+        -      ;*_\*',,_            /*|;|,      -     \^;/'^|\`\\            ".|\\,     -    ~* +`  |*/;||,           '.\||,    -   +^"-*    '\|*/"|_          ! |/|    -   ||_|`     ,//|;|*            "`|    -   |=~'`    ;||^\|".~++++++_+, =" |    -    _~;*  _;+` /* |"|___.:,,,|/,/,|    -    \^_"^ ^\,./`   `^*''* ^*"/,;_/     -     *^, ", `              ,'/*_|      -       ^\,`\+_          _=_+|_+"       -         ^*,\_!*+:;=;;.=*+_,|*         -           `*"*|~~___,_;+*"            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_10.txt b/codex-rs/tui2/frames/default/frame_10.txt deleted file mode 100644 index 9d45417346..0000000000 --- a/codex-rs/tui2/frames/default/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              _+***\++_                -             *'`+*+\~/_*,              -            ^_,||/~~-~+\,,             -           |__/\|;_.\,''\\,            -           / ;||"|^  /_/|/            -          |` '|*~//\   `_"|            -          \  ~*"*||~|*   |/,           -          "  ||\+/+||-_ .\||           -          "  ~\ \\|;~~+\+;||           -          |  ,|\,|_/_*___|*`           -           , "|||||""!\,"\|`           -           \`',\,*"  "",//            -            |' |||~*,:,/|/`            -             ;`**/|+;_!//'             -              *, _*\_,;*               -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_11.txt b/codex-rs/tui2/frames/default/frame_11.txt deleted file mode 100644 index 769e5ae76d..0000000000 --- a/codex-rs/tui2/frames/default/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               ,****++_                -              /" ;|||\\,               -             /"__||;\*/\,              -             |__||=;,_=//              -            _".;\|+\';_||,             -            |+`||+_;;|_/||             -            ** ,||||||_|=\             -            |  ||/||\/ |"|             -            /  '|/||*/+|_|             -            ** _|/=,"/|_|^             -            '`- ||||=/|\\|             -             \_-/|_*/**;|`             -             !_ *|\\^_|;"              -              \+!*||,_/*`              -               \_ '*+_+`               -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_12.txt b/codex-rs/tui2/frames/default/frame_12.txt deleted file mode 100644 index 50cfd73302..0000000000 --- a/codex-rs/tui2/frames/default/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                +***+.                 -               ,=`_/|,\                -               "  |/\+,                -               /+~||=="|               -              | '~|||./|               -              |'..*^"_|"               -              |   ~/\||\               -              |   /+\||"               -              *, ~/||+|~               -              |   /|*;*_               -              |.  |"=**/               -               ,  *|!_,|               -               / **|,*\|               -               '^/",|\|`               -                \ '~\./                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_13.txt b/codex-rs/tui2/frames/default/frame_13.txt deleted file mode 100644 index 04ed71335c..0000000000 --- a/codex-rs/tui2/frames/default/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 /***,                 -                 |__||                 -                 |`_|"                 -                 |**|_                 -                 *  ||                 -                 ":-||                 -                 ,  ||                 -                 +  "|                 -                /+  _~.                -                |"  +=|                -                '`.. ~`                -                 |___|                 -                 |+,|_                 -                 *__|=                 -                 , ."=                 -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_14.txt b/codex-rs/tui2/frames/default/frame_14.txt deleted file mode 100644 index 66e91f7187..0000000000 --- a/codex-rs/tui2/frames/default/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 +***;                 -                 ,/__.\                -                |_||. |                -                ||/|"^~,               -                |||\   |               -                ~*||  '|               -                |||| . *               -                ||\|`  \               -                |||~   "               -                "^//  ;/               -                \|"",.,|               -                |*~|___|               -                /!"|===`               -                |\/*__/                -                 _* '=/                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_15.txt b/codex-rs/tui2/frames/default/frame_15.txt deleted file mode 100644 index 9d8132e3c4..0000000000 --- a/codex-rs/tui2/frames/default/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 ++***~_               -                `,=||^:*,              -               //|*=\|"*|              -               |/` //,.__|             -              ||="=\||/"^|             -              \||-||//|  "             -              ||   ||||~~,|            -              ||/~|+||| '-|            -              ||+,,*|||_.:|            -              |_|;/|\~*. .|            -              |/||||_| ` ;             -              |**.^~|\-  =             -              '|\, ///` ;`             -               |^||\\.+\/              -                \^*^___/               -                   ``                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_16.txt b/codex-rs/tui2/frames/default/frame_16.txt deleted file mode 100644 index 7217fe58b8..0000000000 --- a/codex-rs/tui2/frames/default/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                _=+"**+~_              -               /^||*||\|=\             -              |//"\/=\|| '\            -             /// ;' \|\||**|           -             ||;_ =||*/|`\           -            |||*|  /|= !| ~.|          -            \\|  ,||||/*", |          -            |/; |`||/|||"; `|          -            \\|~|+~/^||"*+  /          -            *"__,==\*|._| ,_|          -            |||+""/*\|;";.~|`          -             ||* |   `//,  /           -             \|*  |  /,/_,|            -              \|~"_*~//+_|             -               ':._=:__;*              -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_17.txt b/codex-rs/tui2/frames/default/frame_17.txt deleted file mode 100644 index 0d873df751..0000000000 --- a/codex-rs/tui2/frames/default/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                ,=+++;;~,_             -             _;**|~~*=*|,"^,           -            ,*\/_==`+,"|||_"\          -           /|/_/|"   |;\~||=\         -           |/_ ~     "/\=\//  ,        -          `=*,/`   ,:/| /,=/|./        -          *!;/|   ,//|_ *"||/=|        -          -"=|!   !//||/ ,||=;*        -          ,/*/\==+~\_|\^:\||| |        -           |"_;__|/*\/||\!\+'+\        -          \\\/"""****\_|*//\ \'        -           \||_*       `/||` ;         -            _\\!*\_   ,',/^_/          -             , ~*+=\+`_;*:|'           -              `+;/_,+~*_+*             -                   `                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_18.txt b/codex-rs/tui2/frames/default/frame_18.txt deleted file mode 100644 index a474a4f3d0..0000000000 --- a/codex-rs/tui2/frames/default/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               _==+==+;~,_             -            _+"_;,++~__,"+;;_          -          _/:|*"*=" "._"+//\ *         -         ,||*.,^" _/=~\;\\\,       -        _\// /|   _\/~/|_\;\\_       -        /\| ,, _/,*,|'-/^/`/~!      -        ||\:/     +/*/|"_/"*|=|=,      -        "\-~|     ^\"||;^   |;|"       -       \"" ,\==;=;+~|,|*/\, |*|`       -        _\\|*\* ~|/__\_~||_;~`\ ,      -        |/|\`""*****` \__/|/*/`-`      -         \\!,\,         ``*"/*_'       -          \^*+^^.      _*^_/\,`        -           '|.**++===^'*_/_,*          -             "~|_/__,.+";+"            -                    `                  \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_19.txt b/codex-rs/tui2/frames/default/frame_19.txt deleted file mode 100644 index e83b78bd3b..0000000000 --- a/codex-rs/tui2/frames/default/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              __==+~+==~._             -           _+|||\/===_*_,|*,,          -         ,*;;/"|+*`    `*:,`*;\        -        /;|*,^`         _==+,^|*,      -       ||/`/`         ,|^"/"|\ |\,     -      |\/*'         _|*~/!./ '.*|      -      ^=|~'        /*^/|+,`   /:/+|    -      ||||         |;"\|",    | |||    -      |*|\,=;;===~~~|+*;*|;   \ "||    -      ^;/,|=*/^*+\,`, ^_ :\_\,/.|_     -      '\"/+|"""""**"   ^\_/|// //'     -       \\^*_\             `//_//'      -        '!_\~~*,        ,;=./|;`       -          '".|*/~+__=;/*" ;*~'         -             "~._:-'_,.^*-^            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_2.txt b/codex-rs/tui2/frames/default/frame_2.txt deleted file mode 100644 index ac205dd4a5..0000000000 --- a/codex-rs/tui2/frames/default/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _._:=+===+,_              -         _+,/*;+||~=~|=_'|*+_          -       ,|*|\**~*``  `"*=*/||;|,        -     _|/_/*',,_           -,\|/|,      -     ,^"/*^|\_\\_           ^,|\|,     -    '/+"`  |*\+/\+           \\|*|     -   ,|'*|    ^/|;|_|,          /"|"|    -   |" \`     ,|*||;*          |'/.|    -   *""=|    ;|^^_|".~++++++++,|_|~*    -    _='|  /||' /* |=\____..__|+/!|!    -    \\\ * *\..|'   `"*******"|,*||     -     '\_./, `              ,+;/,*      -       .\__|+,          ,=_+!_|"       -        `~^;__"*+:;=;;.=*`_||*         -           `*+*+~~____~;/*"            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_20.txt b/codex-rs/tui2/frames/default/frame_20.txt deleted file mode 100644 index bff8cc065f..0000000000 --- a/codex-rs/tui2/frames/default/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              __+=~~=+=,__             -          ,;=";,_,===||_^*\+,          -        ,,"_+*"~*"    `"^"\+*|+_       -      _|"_*|*           _,+|\/*\\      -     _| ,|*           _/!_||^*\||\     -     /`,|'           +*';|"/  '\~|\    -    ;|;+|          ,* .+*^     ,\|/    -    |_^/`          "";:|",     |~"|    -    |/||;,=;======_,';^^\\*    / |\    -    '|^|_" /``____|\  *\\"|=\ ,/ ||    -     \,^\'"""""""*"     \,=||,| /\     -      * ^*/,               _/*.|/      -        \_^*,~_          ,;*`;*'       -          ^,-."~+^;;,:"~"`,/*'         -            `*+~_!=____|*""            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_21.txt b/codex-rs/tui2/frames/default/frame_21.txt deleted file mode 100644 index b23aadbc7c..0000000000 --- a/codex-rs/tui2/frames/default/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             __,=+==+=+,__             -          ,+*``__+~=~+;_`-*+_          -        ;*^_+*^"`     `^~*+_`*,        -      ,*|;"'             _,;*,;*,      -     /,/*`             ,|/_\ \\_^,     -    /"/|             ,**_//    \\^,    -    |'|`           _!/`,/'     \;\"    -     `\            \; "|,       | |    -    | |  ,+;;;;;+++  ^|,"|,    ;/ |    -    | ~\ |_      _,|   ^"`*|,  /"./    -     ; ". ``"""  '`     '*_,; /` ,     -     '+ :;_                 _*" /      -       *_  ^-_          _.;*' ,`       -         *=_ *"*+:~~;:=*""  -`         -            *++;+____,_;+*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_22.txt b/codex-rs/tui2/frames/default/frame_22.txt deleted file mode 100644 index ccc8480d8b..0000000000 --- a/codex-rs/tui2/frames/default/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _,+=+==+=+,_              -         _+/*|._/|/\||;|+/*+_          -       ,;*;~\**`    `"*\**+__+,        -      ;*~**"            ,,|||_*\       -     \|~/'            ,_/|=|./;'|      -    \|*/`           ,~/|;^/` \|\=|     -   |+*\/           ~+|\*/'    ~|/|     -   ||=|`           |\|~^\     |^|"|    -   ||,", +~==;;;=++_\*|_!\,   _|;"!    -   ^|= *_\\,!/,"~//|  \*;_"|,,!/\=     -    \\\_| """""**"`    `:*+_///",`     -     \*\_\,               _*,"|,'      -      '"+; ++_         _.*,"*_/        -        ':*+,"*~;=+;;/=*""',*          -           "~"~_;___-=_.=*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_23.txt b/codex-rs/tui2/frames/default/frame_23.txt deleted file mode 100644 index 406ced01b0..0000000000 --- a/codex-rs/tui2/frames/default/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -            _,+_=+*++=+__              -         _=|+|\_,_==,|_|*|+_           -       ,"+|',;*`    "~:+|\;||,         -      /;|*;/`          _;;\||;\        -     //|`/'          ,/,,'*/*\|\       -    ,\|"/`         _***''/*'|~\|,      -    `||"|         /:/. _|`  "!|'*      -    ~/| |         |"|,_\,   | */|      -    ^"\ |+~;=====;=|_*|_"\_ | |~|      -    |\\_|"="       /`\ \|_\,~ \_|      -     /'| | `"""""""   '`/;=|_/^/`      -      ,||_|.             ,/|\^/`       -       \ |,'/__       _.";*/+/         -         \=\+;*+\~==_++"-_+*`          -           "~!*=\~__+__**`             -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_24.txt b/codex-rs/tui2/frames/default/frame_24.txt deleted file mode 100644 index 73f5639390..0000000000 --- a/codex-rs/tui2/frames/default/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _~_;++*==,_               -          ,"_/"|__~==+,_'+             -        ,"';\/+"~ .`:*~**, \           -       ,'//,/|=,\`~/`!_*\|\_'          -      , //\/,    `""/\_|``\|,'         -      ~,\+\`       *,,/!.|;!/"\        -     |  |~!      ,/_,/"/^\|\^|^        -     | \||       |,=/\_|~_/.`||        -     |. |;;;:++~~~++_*,_| |  ||        -      _ / !*|/,,;;,,|.^\+*| |*|        -      \ '\|\*""""""` \,.*\|=/,`        -       \ \,*\           |!"/;/         -        \.*\`|.      _="_/:_*          -          .-*,\^"~~:==;|^_/`           -            *:_*+;\__==+*              -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_25.txt b/codex-rs/tui2/frames/default/frame_25.txt deleted file mode 100644 index 6fb0cbc16c..0000000000 --- a/codex-rs/tui2/frames/default/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _+=*;++++_                -           ,!*,*`_;\|*||,              -          /|//,,".|+\=\|||_            -         |+*| /! =| "\|,*\/            -        ,__\,/'^;/_|" |//\/*           -        \,~|~_*+.^|: /|,|//|,          -        |/|*| |__/\_/|\/_"|=|          -        |||/|."!~_=\/|\_~=||\          -       ^+\|"|__====+~|\\|+*|\          -        /-"|/|,|_||;*_|\*=/\|          -        \~*/\|`"""""'+/|\|/|`          -         |_"|;+;\-,*:_/,//|/           -          ^`^|_\_*;/,^/_||/            -           \.;\\_*=|**!*|*             -             ~,"*\+,_+|*               -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_26.txt b/codex-rs/tui2/frames/default/frame_26.txt deleted file mode 100644 index 8bd6052839..0000000000 --- a/codex-rs/tui2/frames/default/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              _;***"+,                 -             /*|;/\|+;|,               -            /*""|;\|\""\,              -           ;*",||~_||_+/^              -           |_,\|.~"*\/;|\;             -           \ "|/:/"*_\"\\/             -          |!  *'=||/||;;"+             -          /-  \|||^^=||/||             -          |  .\*;+~+==/\||             -          ! ._|/,|__,*\\*|             -           |`"/|*"\,/`+\||             -           \_~|/\   //"||`             -            *  *|\ ^`/|/,              -             |"*\\"*_**;               -              \/:^*~_\*                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_27.txt b/codex-rs/tui2/frames/default/frame_27.txt deleted file mode 100644 index e8630695b8..0000000000 --- a/codex-rs/tui2/frames/default/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                ;***+,                 -               |;:/|/\                 -              ;'` *|/'|                -              |~~^||;|~                -              |  `|_-'=                -              ~_._"`|||`               -              = "||_*||!               -             |  `||~="||               -             |. .!|+||||               -             '= ."_|_*||               -              |- _^**+/'               -              ||++||_/|                -              |==+=,/|^                -               \__|\=//                -               '\" ^\/                 -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_28.txt b/codex-rs/tui2/frames/default/frame_28.txt deleted file mode 100644 index 3313d8b9bf..0000000000 --- a/codex-rs/tui2/frames/default/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 /**;                  -                 |+_|`                 -                 = ;|`                 -                 |-`*|                 -                 |  ~|                 -                 | -"|                 -                ^|~ _|                 -                 |-""|                 -                /\  _|                 -                ||.:~|                 -                 |  _|                 -                 |=+=|                 -                 |=*||                 -                 *__/|                 -                 ~ .+|                 -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_29.txt b/codex-rs/tui2/frames/default/frame_29.txt deleted file mode 100644 index 2ae088f1b9..0000000000 --- a/codex-rs/tui2/frames/default/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 +****,                -                ,|*/!*\                -                ^,|| '"|               -                ||||^\,/               -                \ ~"|  |               -                |,~|| __               -                |\\||| ^               -                ||=~|                  -                ||~*|   `              -                _|=||   `              -                *||/|^ =               -                |/~~| _|               -                ~|||`~_|               -                |=|*/"|'               -                 ~|* .,                -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_3.txt b/codex-rs/tui2/frames/default/frame_3.txt deleted file mode 100644 index 727e25a8e8..0000000000 --- a/codex-rs/tui2/frames/default/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;++====,_              -         _+,/\||+|"==|;_^|*+_          -       ,;/*;|*""`   `"~!**+/^|,        -      /+/\|;,+_          `=*!||\       -     '/*|/^/||*\_           \^\||_     -    /|\\/  .,|\;\\           | \\|     -    =* |    '*\|_"|,          .*/|,    -   ,-|,|     ,-|"/\|          ~_|=|    -    -"'|    ;|*+~|*-~=++___++_~^";|    -    ,\:\, ./*/;;| |*|__,!=.,;\`|\|`    -    '^| | "||+,"   "***"""**,///,/     -     '*~ \+ `              /^+^//      -       =^//*\,          ,+;/",|'       -         =^+,_**^=;=;,:=*;`_+"         -           `*+*_:~____~;/^"            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_30.txt b/codex-rs/tui2/frames/default/frame_30.txt deleted file mode 100644 index 99eeebce33..0000000000 --- a/codex-rs/tui2/frames/default/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                 _;"**+,               -               _/;||\*'=\              -               "'^|,\\|+,\             -              ||\|/|_\|\ \,            -              =*||`\|,|,  |            -             |*|^+  *||||.|            -             \/|||\_ \/\| =`           -             "| '+=~,|"|-  `           -             "|_"\~=~\/|,  `           -             "||\|__~|!+,  `           -             !\||;\_*~||+~|            -              |*//,/*\||" |            -              \|\||/*~|,~/             -               ,^,+=^/|,/'             -                \-.|^__;'              -                  ````                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_31.txt b/codex-rs/tui2/frames/default/frame_31.txt deleted file mode 100644 index 8d9adf28b2..0000000000 --- a/codex-rs/tui2/frames/default/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                _.:*+*+=,              -              _+*;+,_"+\'*,            -             ,|\//,=_`."|_*\           -            ,~/+__*;_,\\|\~|\          -            ^||||+-*\_,"\|/__          -           ;|^`~_|'"\;*,./|,"|         -           /|| |^*|\.=/;*|*|/|         -           \\, |~"|\ |^""|~\.|         -           |\,_|'^~|~/+~~|_  |         -           "||~//||___\_|\|| *         -           ^*_/+|, /***`/|'~_!         -            |||\\\,     _=* |          -             ;|*=_!,  . |*`/`          -              :|\|/_|`,|",|`           -               '"=~_+*/.;*             -                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_32.txt b/codex-rs/tui2/frames/default/frame_32.txt deleted file mode 100644 index 4175a7a66e..0000000000 --- a/codex-rs/tui2/frames/default/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -                ,++++;;~,_             -              ;*~**~\||**|~,           -            /^*=^,+^:-`*|*\'*,         -           '//|_,`;- - ,_\|+,!,        -          //|,|*\'*|; '`,~\/\*\,       -          \/\\,\*|`:||+_   |+/ *       -         *|";,`'\||,,|,=`/_//|'_       -         |||,_  "_||"/'|;-_"\|""`      -         \||-_ '_|"__|+++~~~=|"=`      -         '""_`|*\'\_____||*|;~-_       -           ,\*||/ |*""***^;///./       -           \,|^\\        ,\|/'~        -           '**||~+_    _/_|/ ,         -             \-\"~+|;=*`_+"_/          -               ~;=__,+/*_""            -                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_33.txt b/codex-rs/tui2/frames/default/frame_33.txt deleted file mode 100644 index dbd9568018..0000000000 --- a/codex-rs/tui2/frames/default/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               _,+=+=+=~._             -            _+*||\*=~:|-|*|*.          -          _/\|+*+,="`  "*/||+",        -         ,\/|/_|,_        *||\_*       -        _.|/`||/\^\         |+\\^      -        //,   \_\\`\,        /\/_\     -        ||+ !  \,*|_|\       |*||      -        *|,,   ,''\/=,       \"|*      -        ||| | ,` /*,,,;==+~~+/_|\~     -        ^/|'"/:,/`~|_____||*^^^~|.     -        \=\, |/|/ / """"*** ||,/^`     -         \+\*,",           //;/=/      -          *\"*,*;,      _+|,/*\'       -            \~"*\+|,;+_";,\*~*         -              "==+,___^+*;-"           -                   ````                \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_34.txt b/codex-rs/tui2/frames/default/frame_34.txt deleted file mode 100644 index 7fc67a92db..0000000000 --- a/codex-rs/tui2/frames/default/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -               _,=++++=~,_             -           _+*+/\;|"~+*=**;,=_         -         _//,|*":~*`   `^\\,"**,       -        ,/_|*_/,_          *|,*\\      -       //|\-/|!|"!,          \\*\^     -      ,/\|`/ \\"|\*\          \,\|\    -      |*^~_   '\_*|_^          |;~_    -      \|=":    |*_|/"|         / _\    -      ,'/."   /\//"_==++++++~__" ~^`   -      ||\,/,,^"//'~||_~.___,/_||`|\    -       /_\, |\|*^!  "**````^ ,/,\|'    -        /,\;=\_             /;.|/'     -         \+`|+\;,        _+="_/,`      -           -\/~"|+,;,+=*=*`+*:'        -             "~\;=_____;=*=^           -                   ```                 \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_35.txt b/codex-rs/tui2/frames/default/frame_35.txt deleted file mode 100644 index 570f34f0de..0000000000 --- a/codex-rs/tui2/frames/default/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              _,+;=+++=+,_             -           ,*=*.~**+"|*~:"~*=_         -        _//|;+*|^*"`  `"*=*."|*,       -       ,//+/,;,_            *+_\|_     -      //~|//\ "\*,            |\*|,    -     ;/;/+` '|_"..*_           |\\|    -     \ !./    \\ '*_.           |^\\   -     ~|~:      /\\;/'           / "/   -     / ,_    ,||/;"/|==_;_=+~~  |~=*   -     |,^;'  //;"*+/|; \,____/"~/' |'   -      /`\,'/\_|/^`  `"^^^^*^^*//_,|    -       ".^\/~               _/* ,^     -        \;`^;,:,          ,;/_**'      -          "^_"~*~-:~~+~;/*"_/;"        -             "^~;=__/__++*"^           -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_36.txt b/codex-rs/tui2/frames/default/frame_36.txt deleted file mode 100644 index 74d83c8e70..0000000000 --- a/codex-rs/tui2/frames/default/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              __+=++++=+,_             -          _=""\+/;/+\+;++"**+_         -        ,\'\,+*-*"`` `"*~*+|,*|,       -      _|"*+____            '*~\"|      -     ,/_;\'|\`\,.             ^\.*     -     / ,/`  *_ "|/,            "\^*    -    | ;!`     !\ "\\            |^|,   -    ||\~      _\ _//!           \| |   -    |'"|     // ,*"',++_+++++_  |\~|   -     _*|\  ,|__/~/ !`~_______|| \/'`   -     ' *|\ +_+/^     "**^^^^^" |,"/    -      ',"\;.                 ,/|"/     -        \/||+~,           ,++"/,`      -          *,_"**=^;~_+~;"-",;+'        -            `*+/~_,,_,,++**"           -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_4.txt b/codex-rs/tui2/frames/default/frame_4.txt deleted file mode 100644 index 06dbce99c0..0000000000 --- a/codex-rs/tui2/frames/default/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;+==+=+,_              -         _-"+*|/!|\=/*;|"/*,_          -       ,*=|||+*"`   `^~\^*|/\\_        -      //|,|".+,          "|**\*\       -     /|/_|=|\;^|,          \"\|*\      -    /||^|  '_||/*\          ' \=|,     -    "\|;`   '**\+"|,         , ";|     -     |.\     ,|/*^||           |||     -    _|!|_   ;|/"^//+~+++____,, ||*     -    |\/!| ,///,_|`=\|,._,:/^;|//"|     -     `|;'\,\\,\/   "*******'^-|||`     -      ,|\"|_`             ,^;/**'      -       \\,:^!,_        _.^,*;|/        -         ~||=_**\;;=;,+=*+\|*`         -            *\\~:~_____;-*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_5.txt b/codex-rs/tui2/frames/default/frame_5.txt deleted file mode 100644 index 6b1ce12447..0000000000 --- a/codex-rs/tui2/frames/default/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;;+===+,_              -         _+"+/*/||+~=+;_|"+,           -        *_/\+|*"`   "^=|*\!,*,         -      ,|/|/|.=,          -^||||_       -     ,\/*/^_+|_\,         ` \|\|_      -     ~|||/ \_|\;/|_          +/||      -    |/|!+   `\_|\"|,        ''~+|,     -    |/|_"    ,_=|/_|          +|_|     -    |,|^*   /_|",|*=_~+~___+,_"|.|     -     _\|\,,/+*" |"!//\=^,=.,/|/*|`     -     \,||,~,\\/+`  "**""""",~;|\/      -      \\+/\\ `            /_/*,^       -       ^/*\|=+_        _=+,*';*        -         ^|*|,*+\;~=_,+=*^~;*          -            ^-*_*"___-_,+*`            -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_6.txt b/codex-rs/tui2/frames/default/frame_6.txt deleted file mode 100644 index 7724f483dc..0000000000 --- a/codex-rs/tui2/frames/default/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.=;;+==++__              -          ,^;/*|=*+|++;_,*+_           -        ,,,|||*"   `"~.=*+*/\_         -       /,|*|*_=,        \+||||,        -      '=|||*\\+*\         .\\|\,       -     ;-/|/`^+;|\_|,        .=\/|       -     _ +~|   !^\|/^\        ^+|_|      -     ~ |~|   _|=~/||        ~+\||      -     _.|-|  /'||*;/+_~+~__++_.\/|      -     ||\,/_^_;+ |/=*,||,;==\|,|\!      -      / | /~||;/"  "*"""'*"`\\/|       -       , ; /,`           ,:_//|`       -        .`\_**,       _+^_/*,+         -         `"~*,"+!;~__,+**!:;'          -            ^-;*+,___`_,+*             -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_7.txt b/codex-rs/tui2/frames/default/frame_7.txt deleted file mode 100644 index 0d0f43072c..0000000000 --- a/codex-rs/tui2/frames/default/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             _.~;*+==+,_               -          ,*`+\|+*+==\;!`*,            -         * ,|||*`  `^~`!*/_*,          -        \\|||~;+       ^~\*|/,         -       `'|*||^|/\,       . *||\        -      | ;||" `'\;|\       ,-|||        -       `\"|  ^_|\|\|       **^||       -      _"/~|   =+/|*|`      ' |||       -       /"~* ,"_/|\/~~;;_~~=;*\||       -      |,'||=:~|/'|.\||\\,=,;\|\|       -       \ =^/*|*_/  ******""_/||        -       '_ /_/_`         ,"-/;/'        -        ';,+\\\_      ,^~,*/*'         -          \_'\|*\;~___+*|*+/           -            "~*"~:~__,_;**             -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_8.txt b/codex-rs/tui2/frames/default/frame_8.txt deleted file mode 100644 index 2e8019c061..0000000000 --- a/codex-rs/tui2/frames/default/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -             __+_;++=+,_               -           ,"*/|||*==!~ "+             -         _|=,|//*!,"~/~*\+^,           -        _*\*/|,+|     ' =^||\          -        ' /|/,\|/\      .'\||,         -       |',|\^^_\|_*      \+|||         -       |*||| '_;\|`|      ^|/|,        -       \,||/  |+\/|,*.    .`/||        -       \ \||_^ !/*=|~/+,+,,\~||        -       !  \*/=_|",|,||;|=__||='        -        //\\|\|||/'^*^*"\*"/\|         -        ',"|\|``        .,///'         -         '\_*\\\_    _/\_|+/*          -           .:'|,*!;;+*;/=,|`           -             ~~_**\|_,+/=`             -                  ``                   \ No newline at end of file diff --git a/codex-rs/tui2/frames/default/frame_9.txt b/codex-rs/tui2/frames/default/frame_9.txt deleted file mode 100644 index 128e915007..0000000000 --- a/codex-rs/tui2/frames/default/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ -                                       -              .=^*/++=,                -            /*_/||*"=!_-\_             -           / ,||/*^^=/!\_~,            -          " ||/;=_ _^,|\^|+,           -         /*";\*"|*,  +:+||           -         | |||"^|\;*   '|*|||          -         ` ~*\ **|\\," / `||          -        |  ~/_ ~||_/= | !||          -        !  ",|" /|"|~~|+|~,||`         -         |_|||_,|^|_||||__|||          -         " `^/\\|/"****||"/\|          -          \~||\,`     ,/ ^/|`          -           \+\|\\    /;_;|/*           -            ^-"\_|*/+=;;*|`            -             '-_ *\\|;+/"              -                                       \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_1.txt b/codex-rs/tui2/frames/dots/frame_1.txt deleted file mode 100644 index 36964a4864..0000000000 --- a/codex-rs/tui2/frames/dots/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉○●●○○●●○ - ○○●◉●○●◉●○○··○○ ○ ●○ - ●·●·●●● ○· · ●· ·○···● - ◉●○○●●●●○ ◉●·◉·● - ○○◉◉●○·○·○○ ◉·○○● - ·● ●· ·●◉◉··● ●◉○··● - ●○ ◉● ●○·●◉ ·○ ·◉· - ··○·· ●◉◉·◉·● ·· - ·○·●· ◉··○○· ◉·●●●●●●○●● ○ · - ○·◉● ○◉●· ◉● · ·○○○◉◉●●●·◉●◉●· - ○○○ ○ ○○●◉◉· ·○●●●● ○● ◉●◉○◉ - ●○● ● · ●●◉●○· - ○○●·○●○ ○○○●·○● - ○●●○○ ●●◉◉○◉◉◉○●●○●·● - ·● ●···○○○●○◉●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_10.txt b/codex-rs/tui2/frames/dots/frame_10.txt deleted file mode 100644 index 3c687d7f64..0000000000 --- a/codex-rs/tui2/frames/dots/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●●●○●●○ - ●●·●●●○·◉○●● - ○○●··◉··◉·●○●● - ·○○◉○·◉○◉○●●●○○● - ◉ ◉·· ·○ ●●◉○◉·◉ - ·· ●·●·◉◉○ ·○ · - ○ ·● ●····● ·◉● - ··○●◉●··◉○·◉○·· - ·○ ○○·◉··●○●◉·· - · ●·○●·○◉○●○○○·●· - ● ····· ○● ○·· - ○·●●○●● ○· ●◉◉ - ·● ····●●◉●◉·◉· - ◉·●●◉·●◉○ ◉◉● - ●● ○●○○●◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_11.txt b/codex-rs/tui2/frames/dots/frame_11.txt deleted file mode 100644 index c2548db4b3..0000000000 --- a/codex-rs/tui2/frames/dots/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●●●○ - ◉ ◉···○○● - ◉ ○○··◉○●◉○● - ·○○··○◉●○○◉◉ - ○ ◉◉○·●○●◉○··● - ·●···●○◉◉·○◉·· - ●● ●······○·○○ - · ··◉··○◉ · · - ◉ ●·◉··●◉●·○· - ●● ○·◉○● ◉·○·○ - ●·◉ ····○◉·○○· - ○○◉◉·○●◉●●◉·· - ○ ●·○○○○·◉ - ○● ●··●○◉●· - ○○ ●●●○●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_12.txt b/codex-rs/tui2/frames/dots/frame_12.txt deleted file mode 100644 index 30b03392bf..0000000000 --- a/codex-rs/tui2/frames/dots/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●◉ - ●○·○◉·●○ - ·◉○●● - ◉●···○○ · - · ●····◉◉· - ·●◉◉●○ ○· - · ·◉○··○ - · ◉●○·· - ●● ·◉··●·· - · ◉·●◉●○ - ·◉ · ○●●◉ - ● ●· ○●· - ◉ ●●·●●○· - ●○◉ ●·○·· - ○ ●·○◉◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_13.txt b/codex-rs/tui2/frames/dots/frame_13.txt deleted file mode 100644 index cb95f3763d..0000000000 --- a/codex-rs/tui2/frames/dots/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉●●●● - ·○○·· - ··○· - ·●●·○ - ● ·· - ◉◉·· - ● ·· - ● · - ◉● ○·◉ - · ●○· - ●·◉◉ ·· - ·○○○· - ·●●·○ - ●○○·○ - ● ◉ ○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_14.txt b/codex-rs/tui2/frames/dots/frame_14.txt deleted file mode 100644 index 3a8ed60b8f..0000000000 --- a/codex-rs/tui2/frames/dots/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●◉ - ●◉○○◉○ - ·○··◉ · - ··◉· ○·● - ···○ · - ·●·· ●· - ···· ◉ ● - ··○·· ○ - ···· - ○◉◉ ◉◉ - ○· ●◉●· - ·●··○○○· - ◉ ·○○○· - ·○◉●○○◉ - ○● ●○◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_15.txt b/codex-rs/tui2/frames/dots/frame_15.txt deleted file mode 100644 index c57b4af0ee..0000000000 --- a/codex-rs/tui2/frames/dots/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●·○ - ·●○··○◉●● - ◉◉·●○○· ●· - ·◉· ◉◉●◉○○· - ··○ ○○··◉ ○· - ○··◉··◉◉· - ·· ······●· - ··◉··●··· ●◉· - ··●●●●···○◉◉· - ·○·◉◉·○·●◉ ◉· - ·◉····○· · ◉ - ·●●◉○··○◉ ○ - ●·○● ◉◉◉· ◉· - ·○··○○◉●○◉ - ○○●○○○○◉ - ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_16.txt b/codex-rs/tui2/frames/dots/frame_16.txt deleted file mode 100644 index 18ae0e09ee..0000000000 --- a/codex-rs/tui2/frames/dots/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○● ●●●·○ - ◉○··●··○·○○ - ·◉◉ ○◉○○·· ●○ - ◉◉◉ ◉●○○·○··●●· - ··◉○◉●●○··●◉··○ - ···●·● ·○○ · ·◉· - ○○· ◉·●····◉● ● · - ·◉◉ ····◉··· ◉ ·· - ○○···●·◉○·· ●● ◉ - ● ○○●○○○●·◉○· ●○· - ···● ◉●○·◉ ◉◉··· - ··● ·· ·◉◉● ◉ - ○·● · ◉●◉○●· - ○·· ○●·◉◉●○· - ●◉◉○○◉○○◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_17.txt b/codex-rs/tui2/frames/dots/frame_17.txt deleted file mode 100644 index a470b4ba8d..0000000000 --- a/codex-rs/tui2/frames/dots/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●○●●●◉◉·●○ - ○◉●●···●○●·● ○● - ●●○◉○○○·●● ···○ ○ - ◉·◉○◉· ○· ●·◉○···○○ - ·◉○ · · ◉○○○◉◉ ● - ·○●●◉· ●◉◉· ◉●○◉·◉◉ - ● ◉◉· ●◉◉·○ ● ··◉○· - ◉ ○· ◉◉··◉·●··○◉● - ●◉●◉○○○●·○○·○○◉○··· · - · ○◉○○·◉●○◉··○ ○●●●○ - ○○○◉ ●●●●○○·●◉◉○ ○● - ○··○● ·◉··· ◉ - ○○○ ●○○ ●●●◉○○◉ - ● ·●●○○●·○◉●◉·● - ·●◉◉○●●·●○●● - · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_18.txt b/codex-rs/tui2/frames/dots/frame_18.txt deleted file mode 100644 index c0354b3933..0000000000 --- a/codex-rs/tui2/frames/dots/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○○●○○●◉·●○ - ○● ○◉●●●·○○● ●◉◉○ - ○◉◉·● ●○ ● ◉○ ●◉◉○ ● - ●··●◉●○ ● ●○·○◉○·○◉○○○● - ○○◉◉ ◉· ○ ●○○◉·◉·○○◉○○○ - ◉○· ●●·◉○○○◉●●●·●◉◉○◉·◉· - ··○◉◉ ●◉●◉· ○◉ ●·○·○● - ○◉·· ○○ ··◉○ ·◉· - ○ ●○○○◉○◉●··●·●◉○● ·●·· - ○○○·●○● ··◉○○○○···○◉··○ ● - ·◉·○· ●●●●●· ○○○◉·◉●◉·◉· - ○○ ●○● ··● ◉●○● - ○○●●○○◉ ○●○○◉○●· - ●·◉●●●●○○○○●●○◉○●● - ··○◉○○●◉● ◉● - · \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_19.txt b/codex-rs/tui2/frames/dots/frame_19.txt deleted file mode 100644 index c9ded56838..0000000000 --- a/codex-rs/tui2/frames/dots/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○○○●·●○○·◉○ - ○●···○◉○○○○●○●·●●● - ●●◉◉◉ ·●●· ·●◉●·●◉○ - ◉◉·●●○· ○○○●●○·●● - ··◉·◉· ●·○ ◉ ·○ ·○● - ·○◉●● ○·●·◉ ◉◉ ●◉●· - ○○··● ◉●○◉·●●· ◉◉◉●· - ···· ·◉ ○· ● · ··· - ·●·○●○◉◉○○○····●●◉●·◉ ○ ·· - ○◉◉●·○●◉○●●○●·● ○○ ◉○○○●◉◉·○ - ●○ ◉●· ●● ○○○◉·◉◉ ◉◉● - ○○○●○○ ·◉◉○◉◉● - ● ○○··●● ●◉○◉◉·◉· - ● ◉·●◉·●○○○◉◉● ◉●·● - ·◉○◉◉●○●◉○●◉○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_2.txt b/codex-rs/tui2/frames/dots/frame_2.txt deleted file mode 100644 index 6e7a27fb29..0000000000 --- a/codex-rs/tui2/frames/dots/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉○●○○○●●○ - ○●●◉●◉●···○··○○●·●●○ - ●·●·○●●·●·· · ●○●◉··◉·● - ○·◉○◉●●●●○ ◉●○·◉·● - ●○ ◉●○·○○○○○ ○●·○·● - ●◉● · ·●○●◉○● ○○·●· - ●·●●· ○◉·◉·○·● ◉ · · - · ○· ●·●··◉● ·●◉◉· - ● ○· ◉·○○○· ◉·●●●●●●●●●·○··● - ○○●· ◉··● ◉● ·○○○○○○◉◉○○·●◉ · - ○○○ ● ●○◉◉·● · ●●●●●●● ·●●·· - ●○○◉◉● · ●●◉◉●● - ◉○○○·●● ●○○● ○· - ··○◉○○ ●●◉◉○◉◉◉○●·○··● - ·●●●●··○○○○·◉◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_20.txt b/codex-rs/tui2/frames/dots/frame_20.txt deleted file mode 100644 index d9809e733c..0000000000 --- a/codex-rs/tui2/frames/dots/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○··○●○●○○ - ●◉○ ◉●○●○○○··○○●○●● - ●● ○●● ·● · ○ ○●●·●○ - ○· ○●·● ○●●·○◉●○○ - ○· ●·● ○◉ ○··○●○··○ - ◉·●·● ●●●◉· ◉ ●○··○ - ◉·◉●· ●● ◉●●○ ●○·◉ - ·○○◉· ◉◉· ● ·· · - ·◉··◉●○◉○○○○○○○●●◉○○○○● ◉ ·○ - ●·○·○ ◉··○○○○·○ ●○○ ·○○ ●◉ ·· - ○●○○● ● ○●○··●· ◉○ - ● ○●◉● ○◉●◉·◉ - ○○○●●·○ ●◉●·◉●● - ○●◉◉ ·●○◉◉●◉ · ·●◉●● - ·●●·○ ○○○○○·● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_21.txt b/codex-rs/tui2/frames/dots/frame_21.txt deleted file mode 100644 index 0821f12d75..0000000000 --- a/codex-rs/tui2/frames/dots/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○●○○●○●●○○ - ●●●··○○●·○·●◉○·◉●●○ - ◉●○○●●○ · ·○·●●○·●● - ●●·◉ ● ○●◉●●◉●● - ◉●◉●· ●·◉○○ ○○○○● - ◉ ◉· ●●●○◉◉ ○○○● - ·●·· ○ ◉·●◉● ○◉○ - ·○ ○◉ ·● · · - · · ●●◉◉◉◉◉●●● ○·● ·● ◉◉ · - · ·○ ·○ ○●· ○ ·●·● ◉ ◉◉ - ◉ ◉ ·· ●· ●●○●◉ ◉· ● - ●● ◉◉○ ○● ◉ - ●○ ○◉○ ○◉◉●● ●· - ●○○ ● ●●◉··◉◉○● ◉· - ●●●◉●○○○○●○◉●●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_22.txt b/codex-rs/tui2/frames/dots/frame_22.txt deleted file mode 100644 index d673349801..0000000000 --- a/codex-rs/tui2/frames/dots/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●○●○○●○●●○ - ○●◉●·◉○◉·◉○··◉·●◉●●○ - ●◉●◉·○●●· · ●○●●●○○●● - ◉●·●● ●●···○●○ - ○··◉● ●○◉·○·◉◉◉●· - ○·●◉· ●·◉·◉○◉· ○·○○· - ·●●○◉ ·●·○●◉● ··◉· - ··○·· ·○··○○ ·○· · - ··● ● ●·○○◉◉◉○●●○○●·○ ○● ○·◉ - ○·○ ●○○○● ◉● ·◉◉· ○●◉○ ·●● ◉○○ - ○○○○· ●● · ·◉●●○◉◉◉ ●· - ○●○○○● ○●● ·●● - ● ●◉ ●●○ ○◉●● ●○◉ - ●◉●●● ●·◉○●◉◉◉○● ●●● - · ·○◉○○○◉○○◉○●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_23.txt b/codex-rs/tui2/frames/dots/frame_23.txt deleted file mode 100644 index 180ab16784..0000000000 --- a/codex-rs/tui2/frames/dots/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●○○●●●●○●○○ - ○○·●·○○●○○○●·○·●·●○ - ● ●·●●◉●· ·◉●·○◉··● - ◉◉·●◉◉· ○◉◉○··◉○ - ◉◉··◉● ●◉●●●●◉●○·○ - ●○· ◉· ○●●●●●◉●●··○·● - ··· · ◉◉◉◉ ○·· ·●● - ·◉· · · ·●○○● · ●◉· - ○ ○ ·●·◉○○○○○◉○·○●·○ ○○ · ··· - ·○○○· ○ ◉·○ ○·○○●· ○○· - ◉●· · · ●·◉◉○·○◉○◉· - ●··○·◉ ●◉·○○◉· - ○ ·●●◉○○ ○◉ ◉●◉●◉ - ○○○●◉●●○·○○○●● ◉○●●· - · ●○○·○○●○○●●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_24.txt b/codex-rs/tui2/frames/dots/frame_24.txt deleted file mode 100644 index 3244b1c6f9..0000000000 --- a/codex-rs/tui2/frames/dots/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○·○◉●●●○○●○ - ● ○◉ ·○○·○○●●○●● - ● ●◉○◉● · ◉·◉●·●●● ○ - ●●◉◉●◉·○●○··◉· ○●○·○○● - ● ◉◉○◉● · ◉○○···○·●● - ·●○●○· ●●●◉ ◉·◉ ◉ ○ - · ·· ●◉○●◉ ◉○○·○○·○ - · ○·· ·●○◉○○··○◉◉··· - ·◉ ·◉◉◉◉●●···●●○●●○· · ·· - ○ ◉ ●·◉●●◉◉●●·◉○○●●· ·●· - ○ ●○·○● · ○●◉●○·○◉●· - ○ ○●●○ · ◉◉◉ - ○◉●○··◉ ○○ ○◉◉○● - ◉◉●●○○ ··◉○○◉·○○◉· - ●◉○●●◉○○○○○●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_25.txt b/codex-rs/tui2/frames/dots/frame_25.txt deleted file mode 100644 index c04ef18b74..0000000000 --- a/codex-rs/tui2/frames/dots/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●○●◉●●●●○ - ● ●●●·○◉○·●··● - ◉·◉◉●● ◉·●○○○···○ - ·●●· ◉ ○· ○·●●○◉ - ●○○○●◉●○◉◉○· ·◉◉○◉● - ○●···○●●◉○·◉ ◉·●·◉◉·● - ·◉·●· ·○○◉○○◉·○◉○ ·○· - ···◉·◉ ·○○○◉·○○·○··○ - ○●○· ·○○○○○○●··○○·●●·○ - ◉◉ ·◉·●·○··◉●○·○●○◉○· - ○·●◉○·· ●●◉·○·◉·· - ·○ ·◉●◉○◉●●◉○◉●◉◉·◉ - ○·○·○○○●◉◉●○◉○··◉ - ○◉◉○○○●○·●● ●·● - ·● ●○●●○●·● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_26.txt b/codex-rs/tui2/frames/dots/frame_26.txt deleted file mode 100644 index 1ecc43beef..0000000000 --- a/codex-rs/tui2/frames/dots/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉●●● ●● - ◉●·◉◉○·●◉·● - ◉● ·◉○·○ ○● - ◉● ●···○··○●◉○ - ·○●○·◉· ●○◉◉·○◉ - ○ ·◉◉◉ ●○○ ○○◉ - · ●●○··◉··◉◉ ● - ◉◉ ○···○○○··◉·· - · ◉○●◉●·●○○◉○·· - ◉○·◉●·○○●●○○●· - ·· ◉·● ○●◉·●○·· - ○○··◉○ ◉◉ ··· - ● ●·○ ○·◉·◉● - · ●○○ ●○●●◉ - ○◉◉○●·○○● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_27.txt b/codex-rs/tui2/frames/dots/frame_27.txt deleted file mode 100644 index 83e62da52e..0000000000 --- a/codex-rs/tui2/frames/dots/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉●●●●● - ·◉◉◉·◉○ - ◉●· ●·◉●· - ···○··◉·· - · ··○◉●○ - ·○◉○ ····· - ○ ··○●·· - · ····○ ·· - ·◉ ◉ ·●···· - ●○ ◉ ○·○●·· - ·◉ ○○●●●◉● - ··●●··○◉· - ·○○●○●◉·○ - ○○○·○○◉◉ - ●○ ○○◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_28.txt b/codex-rs/tui2/frames/dots/frame_28.txt deleted file mode 100644 index 6d460c936d..0000000000 --- a/codex-rs/tui2/frames/dots/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉●●◉ - ·●○·· - ○ ◉·· - ·◉·●· - · ·· - · ◉ · - ○·· ○· - ·◉ · - ◉○ ○· - ··◉◉·· - · ○· - ·○●○· - ·○●·· - ●○○◉· - · ◉●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_29.txt b/codex-rs/tui2/frames/dots/frame_29.txt deleted file mode 100644 index d0d6b3c286..0000000000 --- a/codex-rs/tui2/frames/dots/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●● - ●·●◉ ●○ - ○●·· ● · - ····○○●◉ - ○ · · · - ·●··· ○○ - ·○○··· ○ - ··○·· - ···●· · - ○·○·· · - ●··◉·○ ○ - ·◉··· ○· - ······○· - ·○·●◉ ·● - ··● ◉● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_3.txt b/codex-rs/tui2/frames/dots/frame_3.txt deleted file mode 100644 index 062da3ed89..0000000000 --- a/codex-rs/tui2/frames/dots/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉●●○○○○●○ - ○●●◉○··●· ○○·◉○○·●●○ - ●◉◉●◉·● · · · ●●●◉○·● - ◉●◉○·◉●●○ ·○● ··○ - ●◉●·◉○◉··●○○ ○○○··○ - ◉·○○◉ ◉●·○◉○○ · ○○· - ○● · ●●○·○ ·● ◉●◉·● - ●◉·●· ●◉· ◉○· ·○·○· - ◉ ●· ◉·●●··●◉·○●●○○○●●○·○ ◉· - ●○◉○● ◉◉●◉◉◉· ·●·○○● ○◉●◉○··○·· - ●○· · ··●● ●●● ●●●◉◉◉●◉ - ●●· ○● · ◉○●○◉◉ - ○○◉◉●○● ●●◉◉ ●·● - ○○●●○●●○○◉○◉●◉○●◉·○● - ·●●●○◉·○○○○·◉◉○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_30.txt b/codex-rs/tui2/frames/dots/frame_30.txt deleted file mode 100644 index 4bf02ade3d..0000000000 --- a/codex-rs/tui2/frames/dots/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉ ●●●● - ○◉◉··○●●○○ - ●○·●○○·●●○ - ··○·◉·○○·○ ○● - ○●···○·●·● · - ·●·○● ●····◉· - ○◉···○○ ○◉○· ○· - · ●●○·●· ·◉ · - ·○ ○·○·○◉·● · - ··○·○○·· ●● · - ○··◉○○●···●·· - ·●◉◉●◉●○·· · - ○·○··◉●··●·◉ - ●○●●○○◉·●◉● - ○◉◉·○○○◉● - ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_31.txt b/codex-rs/tui2/frames/dots/frame_31.txt deleted file mode 100644 index 99385ee51f..0000000000 --- a/codex-rs/tui2/frames/dots/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉◉●●●●○● - ○●●◉●●○ ●○●●● - ●·○◉◉●○○·◉ ·○●○ - ●·◉●○○●◉○●○○·○··○ - ○····●◉●○○● ○·◉○○ - ◉·○··○·● ○◉●●◉◉·● · - ◉·· ·○●·○◉○◉◉●·●·◉· - ○○● ·· ·○ ·○ ··○◉· - ·○●○·●○···◉●···○ · - ···◉◉··○○○○○·○·· ● - ○●○◉●·● ◉●●●·◉·●·○ - ···○○○● ○○● · - ◉·●○○ ● ◉ ·●·◉· - ◉·○·◉○··●· ●·· - ● ○·○●●◉◉◉● - ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_32.txt b/codex-rs/tui2/frames/dots/frame_32.txt deleted file mode 100644 index 771e9c9106..0000000000 --- a/codex-rs/tui2/frames/dots/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●●●●●◉◉·●○ - ◉●·●●·○··●●··● - ◉○●○○●●○◉◉·●·●○●●● - ●◉◉·○●·◉◉ ◉ ●○○·●● ● - ◉◉·●·●○●●·◉ ●·●·○◉○●○● - ○◉○○●○●··◉··●○ ·●◉ ● - ●· ◉●·●○··●●·●○·◉○◉◉·●○ - ···●○ ○·· ◉●·◉◉○ ○· · - ○··◉○ ●○· ○○·●●●···○· ○· - ● ○··●○●○○○○○○··●·◉·◉○ - ●○●··◉ ·● ●●●○◉◉◉◉◉◉ - ○●·○○○ ●○·◉●· - ●●●···●○ ○◉○·◉ ● - ○◉○ ·●·◉○●·○● ○◉ - ·◉○○○●●◉●○ - ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_33.txt b/codex-rs/tui2/frames/dots/frame_33.txt deleted file mode 100644 index 4d36c1eb6f..0000000000 --- a/codex-rs/tui2/frames/dots/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●○●○●○·◉○ - ○●●··○●○·◉·◉·●·●◉ - ○◉○·●●●●○ · ●◉··● ● - ●○◉·◉○·●○ ●··○○● - ○◉·◉···◉○○○ ·●○○○ - ◉◉● ○○○○·○● ◉○◉○○ - ··● ○●●·○·○ ·●·· - ●·●● ●●●○◉○● ○ ·● - ··· · ●· ◉●●●●◉○○●··●◉○·○· - ○◉·● ◉◉●◉···○○○○○··●○○○··◉ - ○○○● ·◉·◉ ◉ ●●● ··●◉○· - ○●○●● ● ◉◉◉◉○◉ - ●○ ●●●◉● ○●·●◉●○● - ○· ●○●·●◉●○ ◉●○●·● - ○○●●○○○○●●◉◉ - ···· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_34.txt b/codex-rs/tui2/frames/dots/frame_34.txt deleted file mode 100644 index 4cbd99c143..0000000000 --- a/codex-rs/tui2/frames/dots/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●○●●●●○·●○ - ○●●●◉○◉· ·●●○●●◉●○○ - ○◉◉●·● ◉·●· ·○○○● ●●● - ●◉○·●○◉●○ ●·●●○○ - ◉◉·○◉◉· · ● ○○●○○ - ●◉○··◉ ○○ ·○●○ ○●○·○ - ·●○·○ ●○○●·○○ ·◉·○ - ○·○ ◉ ·●○·◉ · ◉ ○○ - ●●◉◉ ◉○◉◉ ○○○●●●●●●·○○ ·○· - ··○●◉●●○ ◉◉●···○·◉○○○●◉○····○ - ◉○○● ·○·●○ ●●····○ ●◉●○·● - ◉●○◉○○○ ◉◉◉·◉● - ○●··●○◉● ○●○ ○◉●· - ◉○◉· ·●●◉●●○●○●·●●◉● - ·○◉○○○○○○◉○●○○ - ··· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_35.txt b/codex-rs/tui2/frames/dots/frame_35.txt deleted file mode 100644 index 5ccdf711b5..0000000000 --- a/codex-rs/tui2/frames/dots/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○●●◉○●●●○●●○ - ●●○●◉·●●● ·●·◉ ·●○○ - ○◉◉·◉●●·○● · · ●○●◉ ·●● - ●◉◉●◉●◉●○ ●●○○·○ - ◉◉··◉◉○ ○●● ·○●·● - ◉◉◉◉●· ●·○ ◉◉●○ ·○○· - ○ ◉◉ ○○ ●●○◉ ·○○○ - ···◉ ◉○○◉◉● ◉ ◉ - ◉ ●○ ●··◉◉ ◉·○○○◉○○●·· ··○● - ·●○◉● ◉◉◉ ●●◉·◉ ○●○○○○◉ ·◉● ·● - ◉·○●●◉○○·◉○· · ○○○○●○○●◉◉○●· - ◉○○◉· ○◉● ●○ - ○◉·○◉●◉● ●◉◉○●●● - ○○ ·●·◉◉··●·◉◉● ○◉◉ - ○·◉○○○◉○○●●● ○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_36.txt b/codex-rs/tui2/frames/dots/frame_36.txt deleted file mode 100644 index 6a26abaea6..0000000000 --- a/codex-rs/tui2/frames/dots/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○●●●●○●●○ - ○○ ○●◉◉◉●○●◉●● ●●●○ - ●○●○●●●◉● ·· · ●·●●·●●·● - ○· ●●○○○○ ●●·○ · - ●◉○◉○●·○·○●◉ ○○◉● - ◉ ●◉· ●○ ·◉● ○○● - · ◉ · ○ ○○ ·○·● - ··○· ○○ ○◉◉ ○· · - ·● · ◉◉ ●● ●●●●○●●●●●○ ·○·· - ○●·○ ●·○○◉·◉ ··○○○○○○○·· ○◉●· - ● ●·○ ●○●◉○ ●●○○○○○ ·● ◉ - ●● ○◉◉ ●◉· ◉ - ○◉··●·● ●●● ◉●· - ●●○ ●●○○◉·○●·◉ ◉ ●◉●● - ·●●◉·○●●○●●●●●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_4.txt b/codex-rs/tui2/frames/dots/frame_4.txt deleted file mode 100644 index b4496013b5..0000000000 --- a/codex-rs/tui2/frames/dots/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉●○○●○●●○ - ○◉ ●●·◉ ·○○◉●◉· ◉●●○ - ●●○···●● · ·○·○○●·◉○○○ - ◉◉·●· ◉●● ·●●○●○ - ◉·◉○·○·○◉○·● ○ ○·●○ - ◉··○· ●○··◉●○ ● ○○·● - ○·◉· ●●●○● ·● ● ◉· - ·◉○ ●·◉●○·· ··· - ○· ·○ ◉·◉ ○◉◉●·●●●○○○○●● ··● - ·○◉ · ●◉◉◉●○··○○·●◉○●◉◉○◉·◉◉ · - ··◉●○●○○●○◉ ●●●●●●●●○◉···· - ●·○ ·○· ●○◉◉●●● - ○○●◉○ ●○ ○◉○●●◉·◉ - ···○○●●○◉◉○◉●●○●●○·●· - ●○○·◉·○○○○○◉◉●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_5.txt b/codex-rs/tui2/frames/dots/frame_5.txt deleted file mode 100644 index 0905c495b2..0000000000 --- a/codex-rs/tui2/frames/dots/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉◉●○○○●●○ - ○● ●◉●◉··●·○●◉○· ●● - ●○◉○●·● · ○○·●○ ●●● - ●·◉·◉·◉○● ◉○····○ - ●○◉●◉○○●·○○● · ○·○·○ - ····◉ ○○·○◉◉·○ ●◉·· - ·◉· ● ·○○·○ ·● ●●·●·● - ·◉·○ ●○○·◉○· ●·○· - ·●·○● ◉○· ●·●○○·●·○○○●●○ ·◉· - ○○·○●●◉●● · ◉◉○○○●○◉●◉·◉●·· - ○●··●·●○○◉●· ●● ●·◉·○◉ - ○○●◉○○ · ◉○◉●●○ - ○◉●○·○●○ ○○●●●●◉● - ○·●·●●●○◉·○○●●○●○·◉● - ○◉●○● ○○○◉○●●●· - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_6.txt b/codex-rs/tui2/frames/dots/frame_6.txt deleted file mode 100644 index 3f96b66761..0000000000 --- a/codex-rs/tui2/frames/dots/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉○◉◉●○○●●○○ - ●○◉◉●·○●●·●●◉○●●●○ - ●●●···● · ·◉○●●●◉○○ - ◉●·●·●○○● ○●····● - ●○···●○○●●○ ◉○○·○● - ◉◉◉·◉·○●◉·○○·● ◉○○◉· - ○ ●·· ○○·◉○○ ○●·○· - · ··· ○·○·◉·· ·●○·· - ○◉·◉· ◉●··●◉◉●○·●·○○●●○◉○◉· - ··○●◉○○○◉● ·◉○●●··●◉○○○·●·○ - ◉ · ◉···◉◉ ● ●● ·○○◉· - ● ◉ ◉●· ●◉○◉◉·· - ◉·○○●●● ○●○○◉●●● - · ·●● ● ◉·○○●●●● ◉◉● - ○◉◉●●●○○○·○●●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_7.txt b/codex-rs/tui2/frames/dots/frame_7.txt deleted file mode 100644 index aa52e1b869..0000000000 --- a/codex-rs/tui2/frames/dots/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○◉·◉●●○○●●○ - ●●·●○·●●●○○○◉ ·●● - ● ●···●· ·○·· ●◉○●● - ○○····◉● ○·○●·◉● - ·●·●··○·◉○● ◉ ●··○ - · ◉·· ·●○◉·○ ●◉··· - ·○ · ○○·○·○· ●●○·· - ○ ◉·· ○●◉·●·· ● ··· - ◉ ·● ● ○◉·○◉··◉◉○··○◉●○·· - ·●●··○◉··◉●·◉○··○○●○●◉○·○· - ○ ○○◉●·●○◉ ●●●●●● ○◉·· - ●○ ◉○◉○· ● ◉◉◉◉● - ●◉●●○○○○ ●○·●●◉●● - ○○●○·●○◉·○○○●●·●●◉ - ·● ·◉·○○●○◉●● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_8.txt b/codex-rs/tui2/frames/dots/frame_8.txt deleted file mode 100644 index 5791ce70e4..0000000000 --- a/codex-rs/tui2/frames/dots/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ○○●○◉●●○●●○ - ● ●◉···●○○ · ● - ○·○●·◉◉● ● ·◉·●○●○● - ○●○●◉·●●· ● ○○··○ - ● ◉·◉●○·◉○ ◉●○··● - ·●●·○○○○○·○● ○●··· - ·●··· ●○◉○··· ·○·◉·● - ○●··◉ ·●○◉·●●◉ ◉·◉·· - ○ ○··○○ ◉●○··◉●●●●●○··· - ○●◉○○· ●·●··◉·○○○··○● - ◉◉○○·○···◉●○●○● ○● ◉○· - ●● ·○··· ◉●◉◉◉● - ●○○●○○○○ ○◉○○·●◉● - ◉◉●·●● ◉◉●●◉◉○●·· - ··○●●○·○●●◉○· - ·· \ No newline at end of file diff --git a/codex-rs/tui2/frames/dots/frame_9.txt b/codex-rs/tui2/frames/dots/frame_9.txt deleted file mode 100644 index 35588ee1ee..0000000000 --- a/codex-rs/tui2/frames/dots/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◉○○●◉●●○● - ◉●○◉··● ○ ○◉○○ - ◉ ●··◉●○○○◉ ○○·● - ··◉◉○○ ○○●·○○·●● - ◉● ◉○● ·●● ●◉●●◉●·· - · ··· ○·○◉○○ ·●·●··· - · ·●○·●●·○○● ◉●◉ ··· - · ·◉○●○···○◉○○○· ·· - ●· ◉· ····●··●··· - ·○···○●·○·○····○○··· - ·○◉○○·◉ ●●●●·· ◉○· - ○···○●· ●◉ ○◉·· - ○●○·○○ ◉◉○◉·◉● - ○◉ ○○·●◉●○◉◉●·· - ●◉○ ●○○·◉●◉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_1.txt b/codex-rs/tui2/frames/hash/frame_1.txt deleted file mode 100644 index 45adbbac24..0000000000 --- a/codex-rs/tui2/frames/hash/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.-A*##**##- - -*#A**#A#**..*- -█#- - #.*.#**█-- -█*-█.*...# - **-**█##- A*.*.# - *-*A█-.*-** █..**# - .* #- .*A*..# █.*..# - #-█-* █*.*A█.- .A. - ..-.- #AA.*.* █-. - .*.█- *..-*.█..######-## *█ . - -.** -*#- A* .█.---.A###.A#A#. - *--█- -*#.A- --*██* -*█A#*-A - *-# █# - #█A*-. - -*#-*#- -*-#.-#█ - -*#*- *#A****.**#-#.* - -*█*...---#-*#*█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_10.txt b/codex-rs/tui2/frames/hash/frame_10.txt deleted file mode 100644 index 0e9a76d4d8..0000000000 --- a/codex-rs/tui2/frames/hash/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -#****##- - *█-#*#*.A-*# - --#..A..-.#*## - .--A*.*-.*#██**# - A *..█.- █#A-A.A - .- █.*.AA* --█. - * .*█*....* .A# - █ ..*#A#..---.*.. - █ .* **.*..#*#*.. - . #.*#.-A-*---.*- - # █.....██ *#█*.- - *-█#*#*█ -.██#AA - .█ ....*#A#A.A- - *-**A.#*- AA█ - *# -**-#** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_11.txt b/codex-rs/tui2/frames/hash/frame_11.txt deleted file mode 100644 index b7e743b218..0000000000 --- a/codex-rs/tui2/frames/hash/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #****##- - A█ *...**# - A█--..***A*# - .--..**#-*AA - -█.**.#*█*-..# - .#-..#-**.-A.. - ** #......-.** - . ..A..*A .█. - A █.A..*A#.-. - ** -.A*#█A.-.- - █-- ....*A.**. - *--A.-*A***.- - - *.**--.*█ - *# *..#-A*- - *- █*#-#- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_12.txt b/codex-rs/tui2/frames/hash/frame_12.txt deleted file mode 100644 index 0c6c85043f..0000000000 --- a/codex-rs/tui2/frames/hash/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #***#. - #*--A.#* - █ .A*## - A#...**█. - . █.....A. - .█..*-█-.█ - . .A*..* - . A#*..█ - *# .A..#.. - . A.***- - .. .█***A - # *. -#. - A **.#**. - █-A█#.*.- - * █.*.A - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_13.txt b/codex-rs/tui2/frames/hash/frame_13.txt deleted file mode 100644 index 097cd508d7..0000000000 --- a/codex-rs/tui2/frames/hash/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - A***# - .--.. - .--.█ - .**.- - * .. - █A-.. - # .. - # █. - A# -.. - .█ #*. - █-.. .- - .---. - .##.- - *--.* - # .█* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_14.txt b/codex-rs/tui2/frames/hash/frame_14.txt deleted file mode 100644 index 8eca909504..0000000000 --- a/codex-rs/tui2/frames/hash/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #**** - #A--.* - .-... . - ..A.█-.# - ...* . - .*.. █. - .... . * - ..*.- * - .... █ - █-AA *A - *.██#.#. - .*..---. - A █.***- - .*A*--A - -* █*A - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_15.txt b/codex-rs/tui2/frames/hash/frame_15.txt deleted file mode 100644 index cbf646ab35..0000000000 --- a/codex-rs/tui2/frames/hash/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ##***.- - -#*..-A*# - AA.***.█*. - .A- AA#.--. - ..*█**..A█-. - *..-..AA. █ - .. ......#. - ..A..#... █-. - ..###*...-.A. - .-.*A.*.*. .. - .A....-. - * - .**.-..*- * - █.*# AAA- *- - .-..**.#*A - *-*----A - -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_16.txt b/codex-rs/tui2/frames/hash/frame_16.txt deleted file mode 100644 index 82698755af..0000000000 --- a/codex-rs/tui2/frames/hash/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -*#█**#.- - A-..*..*.** - .AA█*A**.. █* - AAA **-*.*..**. - ..*-A*█*..*A.-* - ...*.█ .** . ... - **. A-#....A*█# . - .A* .-..A...█* -. - **...#.A-..█*# A - *█--#****..-. #-. - ...#██A**.*█*...- - ..* .- -AA# A - *.* . A#A-#. - *..█-*.AA#-. - █A.-*A--** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_17.txt b/codex-rs/tui2/frames/hash/frame_17.txt deleted file mode 100644 index 57d02179e7..0000000000 --- a/codex-rs/tui2/frames/hash/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #*###**.#- - -***...***.#█-# - #**A-**-##█...-█* - A.A-A.█-- █.**...** - .A- . .█A***AA # - -**#A- #AA. A#*A..A - * *A. #AA.- *█..A*. - -█*. AA..A.#..*** - #A*A***#.*-.*-A*... . - .█-*--.A**A..* *#█#* - ***A███*****-.*AA* *█ - *..-* -A..- * - -** **- #█#A--A - # .*#**#--**A.█ - -#*A-##.*-#* - - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_18.txt b/codex-rs/tui2/frames/hash/frame_18.txt deleted file mode 100644 index ef524a0ed9..0000000000 --- a/codex-rs/tui2/frames/hash/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -**#**#*.#- - -#█-*###.--#█#**- - -AA.*█**█#█.-█#AA* * - #..*.#-█* █*--A*.*****# - -*AA A. -█#-*A.A.-****- - A*. ##..---A#*#.█-A-A-A. - ..*AA #A*A.█-A█*.*.*# - █*-.. -*█..*- .*.█ - *██ #******#..#.*A*# .*.- - -**.*** ..A--*-...-*.-* # - .A.*-██*****- *--A.A*A--- - ** #*# --*█A*-█ - *-*#--. -*--A*#- - █..**##***-█*-A-#* - █..-A--#.#█*#█ - - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_19.txt b/codex-rs/tui2/frames/hash/frame_19.txt deleted file mode 100644 index 80a9abf012..0000000000 --- a/codex-rs/tui2/frames/hash/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --**#.#**..- - -#...*A***-*-#.*## - #***A█.#*- -*A#-*** - A*.*#-- -**##-.*# - ..A-A- #.-█A█.* .*# - .*A*█ -.*.A .A █.*. - -*..█ A*-A.##- AAA#. - .... .*█*.█# . ... - .*.*#******....#***.* * █.. - -*A#.**A-*#*#-# -- A*-*#A..- - █*█A#.█████**█ -*-A.AA AA█ - **-*-* -AA-AA█ - █ -*..*# #**.A.*- - ██..*A.#--**A*█ **.█ - █..-A-█-#.-*-- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_2.txt b/codex-rs/tui2/frames/hash/frame_2.txt deleted file mode 100644 index 843df90f28..0000000000 --- a/codex-rs/tui2/frames/hash/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.-A*#***##- - -##A**#...*..*-█.*#- - #.*.***.*-- -█***A..*.# - -.A-A*█##- -#*.A.# - #-█A*-.*-**- -#.*.# - █A#█- .**#A*# **.*. - #.█*. -A.*.-.# A█.█. - .█ *- #.*..** .█A.. - *██*. *.---.█..#########.-..* - -*█. A..█ A* .**----..--.#A . - *** * **...█ -█*******█.#*.. - █*-.A# - ##*A#* - .*--.## #*-# -.█ - -.-*--█*#A****.**--..* - -*#*#..----.*A*█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_20.txt b/codex-rs/tui2/frames/hash/frame_20.txt deleted file mode 100644 index b588df3894..0000000000 --- a/codex-rs/tui2/frames/hash/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#*..*#*#-- - #**█*#-#***..--**## - ##█-#*█.*█ -█-█*#*.#- - -.█-*.* -##.*A*** - -. #.* -A -..-**..* - A-#.█ #*█*.█A █*..* - *.*#. #* .#*- #*.A - .--A- ██*A.█# ..█. - .A..*#********-#█*--*** A .* - █.-.-█ A------.* ***█.** #A .. - *#-*████████*█ *#*..#. A* - * -*A# -A*..A - *--*#.- #**-**█ - -#-.█.#-**#A█.█-#A*█ - -*#.- *----.*██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_21.txt b/codex-rs/tui2/frames/hash/frame_21.txt deleted file mode 100644 index 0d1fc7ec26..0000000000 --- a/codex-rs/tui2/frames/hash/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#*#**#*##-- - ##*----#.*.#*---*#- - **--#*-█- --.*#--*# - #*.*██ -#**#**# - A#A*- #.A-* **--# - A█A. #**-AA **-# - .█.- - A-#A█ ***█ - -* ** █.# . . - . . ##*****### -.#█.# *A . - . .* .- -#. -█-*.# A█.A - * █. --███ █- █*-#* A- # - █# A*- -*█ A - *- --- -.**█ #- - **- *█*#A..*A**██ -- - *##*#----#-*#*- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_22.txt b/codex-rs/tui2/frames/hash/frame_22.txt deleted file mode 100644 index 8fbfdb5713..0000000000 --- a/codex-rs/tui2/frames/hash/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##*#**#*##- - -#A*..-A.A*..*.#A*#- - #***.***- -█****#--## - **.**█ ##...-** - *..A█ #-A.*..A*█. - *.*A- #.A.*-A- *.**. - .#**A .#.**A█ ..A. - ..*.- .*..-* .-.█. - ..#█# #.******##-**.- *# -.*█ - -.* *-**# A#█.AA. ***-█.## A** - ***-. █████**█- -A*#-AAA█#- - ***-*# -*#█.#█ - ██#* ##- -.*#█*-A - █A*##█*.**#**A**███#* - █.█.-*----*-.**- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_23.txt b/codex-rs/tui2/frames/hash/frame_23.txt deleted file mode 100644 index ef2f8adb70..0000000000 --- a/codex-rs/tui2/frames/hash/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##-*#*##*#-- - -*.#.*-#-**#.-.*.#- - #█#.█#**- █.A#.**..# - A*.**A- -***..** - AA.-A█ #A##█*A**.* - #*.█A- -***██A*█..*.# - -..█. AAA. -.- █ .█* - .A. . .█.#-*# . *A. - -█* .#.********.-*.-█*- . ... - .**-.█*█ A-* *.-*#. *-. - A█. . -███████ █-A**.-A-A- - #..-.. #A.*-A- - * .#█A-- -.█**A#A - ***#**#*.**-##█--#*- - █. ***.--#--**- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_24.txt b/codex-rs/tui2/frames/hash/frame_24.txt deleted file mode 100644 index 09a7fd520c..0000000000 --- a/codex-rs/tui2/frames/hash/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.-*##***#- - #█-A█.--.**##-█# - #██**A#█. .-A*.**# * - #█AA#A.*#*-.A- -**.*-█ - # AA*A# -██A*-.--*.#█ - .#*#*- *##A ..* A█* - . .. #A-#A█A-*.*-.- - . *.. .#*A*-..-A.-.. - .. .***A##...##-*#-. . .. - - A *.A##**##..-*#*. .*. - * █*.**██████- *#.**.*A#- - * *#** . █A*A - *.**-.. -*█-AA-* - .-*#*-█..A***.--A- - *A-*#**--**#* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_25.txt b/codex-rs/tui2/frames/hash/frame_25.txt deleted file mode 100644 index af8bb947f6..0000000000 --- a/codex-rs/tui2/frames/hash/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -#***####- - # *#*--**.*..# - A.AA##█..#***...- - .#*. A *. █*.#**A - #--*#A█-*A-.█ .AA*A* - *#...-*#.-.A A.#.AA.# - .A.*. .--A*-A.*A-█.*. - ...A..█ .-**A.*-.*..* - -#*.█.--****#..**.#*.* - A-█.A.#.-..**-.***A*. - *.*A*.-██████#A.*.A.- - .-█.*#**-#*A-A#AA.A - ---.-*-**A#-A-..A - *.***-**.** *.* - .#█**##-#.* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_26.txt b/codex-rs/tui2/frames/hash/frame_26.txt deleted file mode 100644 index 7ff85c300a..0000000000 --- a/codex-rs/tui2/frames/hash/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -****█## - A*.*A*.#*.# - A*██.**.*██*# - **█#...-..-#A- - .-#*...█**A*.** - * █.AAA█*-*█**A - . *█*..A..**█# - A- *...--*..A.. - . .***#.#**A*.. - .-.A#.--#****. - .-█A.*█*#A-#*.. - *-..A* AA█..- - * *.* --A.A# - .█***█*-*** - *AA-*.-** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_27.txt b/codex-rs/tui2/frames/hash/frame_27.txt deleted file mode 100644 index 06e988b076..0000000000 --- a/codex-rs/tui2/frames/hash/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ****## - .*AA.A* - *█- *.A█. - ...-..*.. - . -.--█* - .-.-█-...- - * █..-*.. - . -...*█.. - .. . .#.... - █* .█-.-*.. - .- --**#A█ - ..##..-A. - .**#*#A.- - *--.**AA - █*█ -*A - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_28.txt b/codex-rs/tui2/frames/hash/frame_28.txt deleted file mode 100644 index 0e25818145..0000000000 --- a/codex-rs/tui2/frames/hash/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - A*** - .#-.- - * *.- - .--*. - . .. - . -█. - -.. -. - .-██. - A* -. - ...A.. - . -. - .*#*. - .**.. - *--A. - . .#. - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_29.txt b/codex-rs/tui2/frames/hash/frame_29.txt deleted file mode 100644 index 7f2ddab00a..0000000000 --- a/codex-rs/tui2/frames/hash/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #****# - #.*A ** - -#.. ██. - ....-*#A - * .█. . - .#... -- - .**... - - ..*.. - ...*. - - -.*.. - - *..A.- * - .A... -. - ....-.-. - .*.*A█.█ - ..* .# - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_3.txt b/codex-rs/tui2/frames/hash/frame_3.txt deleted file mode 100644 index 8cce426bb4..0000000000 --- a/codex-rs/tui2/frames/hash/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.**##****#- - -##A*..#.█**.*--.*#- - #*A**.*██- -█. **#A-.# - A#A*.*##- -** ..* - █A*.A-A..**- *-*..- - A.**A .#.**** . **. - ** . █**.-█.# .*A.# - #-.#. #-.█A*. .-.*. - -██. *.*#..*-.*##---##-.-█*. - #*A*# .A*A**. .*.--# *.#**-.*.- - █-. . █..##█ █***███**#AAA#A - █*. *# - A-#-AA - *-AA**# ##*A█#.█ - *-##-**-****#A***--#█ - -*#*-A.----.*A-█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_30.txt b/codex-rs/tui2/frames/hash/frame_30.txt deleted file mode 100644 index 24a2165e45..0000000000 --- a/codex-rs/tui2/frames/hash/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -*█**## - -A*..**█** - ██-.#**.##* - ..*.A.-*.* *# - **..-*.#.# . - .*.-# *...... - *A...*- *A*. *- - █. █#*.#.█.- - - █.-█*.*.*A.# - - █..*.--.. ## - - *..**-*...#.. - .*AA#A**..█ . - *.*..A*..#.A - #-##*-A.#A█ - *-..---*█ - ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_31.txt b/codex-rs/tui2/frames/hash/frame_31.txt deleted file mode 100644 index 65f139ab96..0000000000 --- a/codex-rs/tui2/frames/hash/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.A*#*#*# - -#**##-█#*█*# - #.*AA#*--.█.-** - #.A#--**-#**.*..* - -....#-**-#█*.A-- - *.--.-.██***#.A.#█. - A.. .-*.*.*A**.*.A. - **# ..█.* .-██..*.. - .*#-.█-...A#...- . - █...AA..---*-.*.. * - -*-A#.# A***-A.█.- - ...***# -** . - *.**- # . .*-A- - A.*.A-.-#.█#.- - ██*.-#*A.** - ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_32.txt b/codex-rs/tui2/frames/hash/frame_32.txt deleted file mode 100644 index 6cbec21aec..0000000000 --- a/codex-rs/tui2/frames/hash/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - #####**.#- - **.**.*..**..# - A-**-##-A--*.**█*# - █AA.-#-*- - #-*.## # - AA.#.**█*.* █-#.*A***# - *A**#**.-A..#- .#A * - *.█*#-█*..##.#*-A-AA.█- - ...#- █-..█A█.*--█*.██- - *..-- █-.█--.###...*.█*- - ███--.**█*-----..*.*.-- - #**..A .*██***-*AAA.A - *#.-** #*.A█. - █**...#- -A-.A # - *-*█.#.***--#█-A - .**--##A*-██ - --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_33.txt b/codex-rs/tui2/frames/hash/frame_33.txt deleted file mode 100644 index a661feb2af..0000000000 --- a/codex-rs/tui2/frames/hash/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##*#*#*..- - -#*..***.A.-.*.*. - -A*.#*##*█- █*A..#█# - #*A.A-.#- *..*-* - -..A-..A*-* .#**- - AA# *-**-*# A*A-* - ..# *#*.-.* .*.. - *.## #██*A*# *█.* - ... . #- A*###***#..#A-.*. - -A.██AA#A-..-----..*---... - ***# .A.A A ████*** ..#A-- - *#**#█# AA*A*A - **█*#**# -#.#A**█ - *.█**#.#*#-█*#**.* - █**##----#**-█ - ---- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_34.txt b/codex-rs/tui2/frames/hash/frame_34.txt deleted file mode 100644 index 3427025326..0000000000 --- a/codex-rs/tui2/frames/hash/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -#*####*.#- - -#*#A**.█.#*****#*- - -AA#.*█A.*- --**#█**# - #A-.*-A#- *.#*** - AA.*-A. .█ # ****- - #A*.-A **█.*** *#*.* - .*-.- █*-*.-- .*.- - *.*█A .*-.A█. A -* - #█A.█ A*AA█-**######.--█ .-- - ..*#A##-█AA█...-..---#A-..-.* - A-*# .*.*- █**----- #A#*.█ - A#****- A*..A█ - *#-.#**# -#*█-A#- - -*A.█.##*##****-#*A█ - █.***-----****- - --- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_35.txt b/codex-rs/tui2/frames/hash/frame_35.txt deleted file mode 100644 index e0919ec5d0..0000000000 --- a/codex-rs/tui2/frames/hash/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -##**###*##- - #***..**#█.*.A█.**- - -AA.*#*.-*█- -█***.█.*# - #AA#A#*#- *#-*.- - AA..AA* █**# .**.# - *A*A#- █.-█..*- .**. - * .A ** █*-. .-** - ...A A***A█ A █A - A #- #..A*█A.**-*-*#.. ..** - .#-*█ AA*█*#A.* *#----A█.A█ .█ - A-*#█A*-.A-- -█----*--*AA-#. - █.-*A. -A* #- - **--*#A# #*A-**█ - █--█.*.-A..#.*A*█-A*█ - █-.**--A--##*█- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_36.txt b/codex-rs/tui2/frames/hash/frame_36.txt deleted file mode 100644 index 0355f68b47..0000000000 --- a/codex-rs/tui2/frames/hash/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#*####*##- - -*██*#A*A#*#*##█**#- - #*█*##*-*█-- -█*.*#.#*.# - -.█*#---- █*.*█. - #A-**█.*-*#. -*.* - A #A- *- █.A# █*-* - . * - * █** .-.# - ..*. -* -AA *. . - .██. AA #*██###-#####- .*.. - -*.* #.--A.A -.-------.. *A█- - █ *.* #-#A- █**-----█ .#█A - █#█**. #A.█A - *A..#.# ###█A#- - *#-█***-*.-#.*█-█#*#█ - -*#A.-##-####**█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_4.txt b/codex-rs/tui2/frames/hash/frame_4.txt deleted file mode 100644 index 2b4b7c670b..0000000000 --- a/codex-rs/tui2/frames/hash/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.**#**#*##- - --█#*.A .**A**.█A*#- - #**...#*█- --.*-*.A**- - AA.#.█.## █.***** - A.A-.*.**-.# *█*.** - A..-. █-..A** █ **.# - █*.*- █***#█.# # █*. - ..* #.A*-.. ... - -. .- *.A█-AA#.###----## ..* - .*A . #AAA#-.-**.#.-#AA-*.AA█. - -.*█*#**#*A █*******█--...- - #.*█.-- #-*A**█ - **#A- #- -.-#**.A - ...*-*******##**#*.*- - ***.A.-----*-*- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_5.txt b/codex-rs/tui2/frames/hash/frame_5.txt deleted file mode 100644 index c71575690b..0000000000 --- a/codex-rs/tui2/frames/hash/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.***#***##- - -#█#A*A..#.*#*-.█## - *-A*#.*█- █-*.** #*# - #.A.A..*# --....- - #*A*A--#.-*# - *.*.- - ....A *-.**A.- #A.. - .A. # -*-.*█.# ██.#.# - .A.-█ #-*.A-. #.-. - .#.-* A-.█#.**-.#.---##-█... - -*.*##A#*█ .█ AA**-#*.#A.A*.- - *#..#.#**A#- █**█████#.*.*A - **#A** - A-A*#- - -A**.*#- -*##*█** - -.*.#*#**.*-##**-.** - --*-*█-----##*- - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_6.txt b/codex-rs/tui2/frames/hash/frame_6.txt deleted file mode 100644 index 799e3a1cf5..0000000000 --- a/codex-rs/tui2/frames/hash/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -.***#**##-- - #-*A*.**#.##*-#*#- - ###...*█ -█..**#*A*- - A#.*.*-*# *#....# - █*...***#** .**.*# - *-A.A--#*.*-.# .**A. - - #.. -*.A-* -#.-. - . ... -.*.A.. .#*.. - -..-. A█..**A#-.#.--##-.*A. - ..*#A---*# .A**#..#****.#.* - A . A...*A█ █*████*█-**A. - # * A#- #A-AA.- - .-*-**# -#--A*## - -█.*#█# *.--##** A*█ - --**##-----##* - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_7.txt b/codex-rs/tui2/frames/hash/frame_7.txt deleted file mode 100644 index 4a3f9f202f..0000000000 --- a/codex-rs/tui2/frames/hash/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -..**#**##- - #*-#*.#*#**** -*# - * #...*- --.- *A-*# - **....*# -.**.A# - -█.*..-.A*# . *..* - . *..█ -█**.* #-... - -*█. --.*.*. **-.. - -█A.. *#A.*.- █ ... - A█.* #█-A.*A..**-..****.. - .#█..*A..A█..*..**#*#**.*. - * *-A*.*-A ******██-A.. - █- A-A-- #█-A*A█ - █*##***- #-.#*A*█ - *-█*.***.---#*.*#A - █.*█.A.--#-*** - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_8.txt b/codex-rs/tui2/frames/hash/frame_8.txt deleted file mode 100644 index 4bc5a6f118..0000000000 --- a/codex-rs/tui2/frames/hash/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - --#-*##*##- - #█*A...*** . █# - -.*#.AA* #█.A.**#-# - -***A.##. █ *-..* - █ A.A#*.A* .█*..# - .█#.*---*.-* *#... - .*... █-**.-. .-.A.# - *#..A .#*A.#*. .-A.. - * *..-- A**..A#####*... - **A*-.█#.#..*.*--..*█ - AA**.*...A█-*-*█**█A*. - █#█.*.-- .#AAA█ - █*-****- -A*-.#A* - .A█.#* **#**A*#.- - ..-***.-##A*- - -- \ No newline at end of file diff --git a/codex-rs/tui2/frames/hash/frame_9.txt b/codex-rs/tui2/frames/hash/frame_9.txt deleted file mode 100644 index db3507db59..0000000000 --- a/codex-rs/tui2/frames/hash/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - .*-*A##*# - A*-A..*█* --*- - A #..A*--*A *-.# - █ ..A**- --#.*-.## - A*█***█.*# *.█#A#.. - . ...█-.**-- .█.*... - - .**.**.**#█.#A -.. - . .A-#*...-A**-. .. - █#.█ A.█....#..#..- - .-...-#.-.-....--... - █ --A**.A█****..█A*. - *...*#- #A -A.- - *#*.** A*-*.A* - --█*-.*A#****.- - █-- ***.*#A█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_1.txt b/codex-rs/tui2/frames/hbars/frame_1.txt deleted file mode 100644 index ab8be3eb1e..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▂▅▄▇▇▄▄▇▆▂ - ▂▄▆▅▇▃▇▅▇▃▄▁▁▄▂ ▂█▇▂ - ▆▁▇▁▇▇▇█▃▂ ▂█▇▂█▁▄▁▁▁▇ - ▄▇▂▃▇█▆▆▂ ▅▇▁▄▁▆ - ▃▃▄▅█▃▁▃▂▃▃ █▅▁▃▃▆ - ▁▇ ▇▂ ▁▇▅▄▁▁▆ █▅▃▁▁▆ - ▇▃█▆▇ █▃▁▇▅█▁▂ ▁▅▁ - ▁▁▂▁▂ ▆▅▅▁▄▁▇ █▂▁ - ▁▄▁█▂ ▄▁▁▃▃▁█▅▁▇▇▇▇▇▇▂▇▆ ▄█ ▁ - ▂▁▄▇ ▂▄▇▂ ▅▇ ▁█▁▂▂▂▅▅▆▆▆▁▅▆▅▆▁ - ▃▃▂█▃ ▃▃▆▅▅▂ ▂▃▇██▇ ▃▇█▅▆▄▂▅ - ▇▃▆ █▆ ▂ ▆█▅▇▂▁ - ▃▃▆▂▃▇▂ ▂▄▂▇▁▂▇█ - ▃▇▆▃▂ ▇▇▅▄▄▄▄▅▄▇▇▂▆▁▇ - ▂▇█▇▁▁▁▂▂▂▆▂▄▇▇█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_10.txt b/codex-rs/tui2/frames/hbars/frame_10.txt deleted file mode 100644 index 5e565ce40b..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▇▇▇▇▃▇▇▂ - ▇█▂▇▇▇▃▁▅▂▇▆ - ▃▂▆▁▁▅▁▁▆▁▇▃▆▆ - ▁▂▂▅▃▁▄▂▅▃▆██▃▃▆ - ▅ ▄▁▁█▁▃ █▆▅▂▅▁▅ - ▁▂ █▁▇▁▅▅▃ ▂▂█▁ - ▃ ▁▇█▇▁▁▁▁▇ ▁▅▆ - █ ▁▁▃▇▅▇▁▁▆▂▂▅▃▁▁ - █ ▁▃ ▃▃▁▄▁▁▇▃▇▄▁▁ - ▁ ▆▁▃▆▁▂▅▂▇▂▂▂▁▇▂ - ▆ █▁▁▁▁▁██ ▃▆█▃▁▂ - ▃▂█▆▃▆▇█ ▂▁██▆▅▅ - ▁█ ▁▁▁▁▇▆▅▆▅▁▅▂ - ▄▂▇▇▅▁▇▄▂ ▅▅█ - ▇▆ ▂▇▃▂▆▄▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_11.txt b/codex-rs/tui2/frames/hbars/frame_11.txt deleted file mode 100644 index 5305252a8d..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▆▇▇▇▇▇▇▂ - ▅█ ▄▁▁▁▃▃▆ - ▅█▂▂▁▁▄▃▇▅▃▆ - ▁▂▂▁▁▄▄▆▂▄▅▅ - ▂█▅▄▃▁▇▃█▄▂▁▁▆ - ▁▇▂▁▁▇▂▄▄▁▂▅▁▁ - ▇▇ ▆▁▁▁▁▁▁▂▁▄▃ - ▁ ▁▁▅▁▁▃▅ ▁█▁ - ▅ █▁▅▁▁▇▅▇▁▂▁ - ▇▇ ▂▁▅▄▆█▅▁▂▁▃ - █▂▆ ▁▁▁▁▄▅▁▃▃▁ - ▃▂▆▅▁▂▇▅▇▇▄▁▂ - ▂ ▇▁▃▃▃▂▁▄█ - ▃▇ ▇▁▁▆▂▅▇▂ - ▃▂ █▇▇▂▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_12.txt b/codex-rs/tui2/frames/hbars/frame_12.txt deleted file mode 100644 index cebfe226e1..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▇▅ - ▆▄▂▂▅▁▆▃ - █ ▁▅▃▇▆ - ▅▇▁▁▁▄▄█▁ - ▁ █▁▁▁▁▅▅▁ - ▁█▅▅▇▃█▂▁█ - ▁ ▁▅▃▁▁▃ - ▁ ▅▇▃▁▁█ - ▇▆ ▁▅▁▁▇▁▁ - ▁ ▅▁▇▄▇▂ - ▁▅ ▁█▄▇▇▅ - ▆ ▇▁ ▂▆▁ - ▅ ▇▇▁▆▇▃▁ - █▃▅█▆▁▃▁▂ - ▃ █▁▃▅▅ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_13.txt b/codex-rs/tui2/frames/hbars/frame_13.txt deleted file mode 100644 index 566cc4ffa3..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▅▇▇▇▆ - ▁▂▂▁▁ - ▁▂▂▁█ - ▁▇▇▁▂ - ▇ ▁▁ - █▅▆▁▁ - ▆ ▁▁ - ▇ █▁ - ▅▇ ▂▁▅ - ▁█ ▇▄▁ - █▂▅▅ ▁▂ - ▁▂▂▂▁ - ▁▇▆▁▂ - ▇▂▂▁▄ - ▆ ▅█▄ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_14.txt b/codex-rs/tui2/frames/hbars/frame_14.txt deleted file mode 100644 index 380790e11c..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▄ - ▆▅▂▂▅▃ - ▁▂▁▁▅ ▁ - ▁▁▅▁█▃▁▆ - ▁▁▁▃ ▁ - ▁▇▁▁ █▁ - ▁▁▁▁ ▅ ▇ - ▁▁▃▁▂ ▃ - ▁▁▁▁ █ - █▃▅▅ ▄▅ - ▃▁██▆▅▆▁ - ▁▇▁▁▂▂▂▁ - ▅ █▁▄▄▄▂ - ▁▃▅▇▂▂▅ - ▂▇ █▄▅ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_15.txt b/codex-rs/tui2/frames/hbars/frame_15.txt deleted file mode 100644 index 47d169e98b..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▇▁▂ - ▂▆▄▁▁▃▅▇▆ - ▅▅▁▇▄▃▁█▇▁ - ▁▅▂ ▅▅▆▅▂▂▁ - ▁▁▄█▄▃▁▁▅█▃▁ - ▃▁▁▆▁▁▅▅▁ █ - ▁▁ ▁▁▁▁▁▁▆▁ - ▁▁▅▁▁▇▁▁▁ █▆▁ - ▁▁▇▆▆▇▁▁▁▂▅▅▁ - ▁▂▁▄▅▁▃▁▇▅ ▅▁ - ▁▅▁▁▁▁▂▁ ▂ ▄ - ▁▇▇▅▃▁▁▃▆ ▄ - █▁▃▆ ▅▅▅▂ ▄▂ - ▁▃▁▁▃▃▅▇▃▅ - ▃▃▇▃▂▂▂▅ - ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_16.txt b/codex-rs/tui2/frames/hbars/frame_16.txt deleted file mode 100644 index 3b1fb1fc5d..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄▇█▇▇▇▁▂ - ▅▃▁▁▇▁▁▃▁▄▃ - ▁▅▅█▃▅▄▃▁▁ █▃ - ▅▅▅ ▄▇▂▃▁▃▁▁▇▇▁ - ▁▁▄▂▅▇█▄▁▁▇▅▁▂▃ - ▁▁▁▇▁█ ▁▃▄ ▁ ▁▅▁ - ▃▃▁ ▅▂▆▁▁▁▁▅▇█▆ ▁ - ▁▅▄ ▁▂▁▁▅▁▁▁█▄ ▂▁ - ▃▃▁▁▁▇▁▅▃▁▁█▇▇ ▅ - ▇█▂▂▆▄▄▃▇▁▅▂▁ ▆▂▁ - ▁▁▁▇██▅▇▃▁▄█▄▅▁▁▂ - ▁▁▇ ▁▂ ▂▅▅▆ ▅ - ▃▁▇ ▁ ▅▆▅▂▆▁ - ▃▁▁█▂▇▁▅▅▇▂▁ - █▅▅▂▄▅▂▂▄▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_17.txt b/codex-rs/tui2/frames/hbars/frame_17.txt deleted file mode 100644 index 93817e2ead..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▆▄▇▇▇▄▄▁▆▂ - ▂▄▇▇▁▁▁▇▄▇▁▆█▃▆ - ▆▇▃▅▂▄▄▂▇▆█▁▁▁▂█▃ - ▅▁▅▂▅▁█▂▂ █▁▄▃▁▁▁▄▃ - ▁▅▂ ▁ ▁█▅▃▄▃▅▅ ▆ - ▂▄▇▆▅▂ ▆▅▅▁ ▅▆▄▅▁▅▅ - ▇ ▄▅▁ ▆▅▅▁▂ ▇█▁▁▅▄▁ - ▆█▄▁ ▅▅▁▁▅▁▆▁▁▄▄▇ - ▆▅▇▅▃▄▄▇▁▃▂▁▃▃▅▃▁▁▁ ▁ - ▁█▂▄▂▂▁▅▇▃▅▁▁▃ ▃▇█▇▃ - ▃▃▃▅███▇▇▇▇▃▂▁▇▅▅▃ ▃█ - ▃▁▁▂▇ ▂▅▁▁▂ ▄ - ▂▃▃ ▇▃▂ ▆█▆▅▃▂▅ - ▆ ▁▇▇▄▃▇▂▂▄▇▅▁█ - ▂▇▄▅▂▆▇▁▇▂▇▇ - ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_18.txt b/codex-rs/tui2/frames/hbars/frame_18.txt deleted file mode 100644 index 03d2c5e94b..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄▄▇▄▄▇▄▁▆▂ - ▂▇█▂▄▆▇▇▁▂▂▆█▇▄▄▂ - ▂▅▅▁▇█▇▄█▆█▅▂█▇▅▅▃ ▇ - ▆▁▁▇▅▆▃█▇ █▃▂▂▅▄▁▃▄▃▃▃▆ - ▂▃▅▅ ▅▁ ▂█▇▂▃▅▁▅▁▂▃▄▃▃▂ - ▅▃▁ ▆▆▁▅▂▂▂▅▆▇▆▁█▆▅▃▅▂▅▁ - ▁▁▃▅▅ ▇▅▇▅▁█▂▅█▇▁▄▁▄▆ - █▃▆▁▁ ▃▃█▁▁▄▃ ▁▄▁█ - ▃██ ▆▃▄▄▄▄▄▇▁▁▆▁▇▅▃▆ ▁▇▁▂ - ▂▃▃▁▇▃▇ ▁▁▅▂▂▃▂▁▁▁▂▄▁▂▃ ▆ - ▁▅▁▃▂██▇▇▇▇▇▂ ▃▂▂▅▁▅▇▅▂▆▂ - ▃▃ ▆▃▆ ▂▂▇█▅▇▂█ - ▃▃▇▇▃▃▅ ▂▇▃▂▅▃▆▂ - █▁▅▇▇▇▇▄▄▄▃█▇▂▅▂▆▇ - █▁▁▂▅▂▂▆▅▇█▄▇█ - ▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_19.txt b/codex-rs/tui2/frames/hbars/frame_19.txt deleted file mode 100644 index f826776170..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▄▄▇▁▇▄▄▁▅▂ - ▂▇▁▁▁▃▅▄▄▄▂▇▂▆▁▇▆▆ - ▆▇▄▄▅█▁▇▇▂ ▂▇▅▆▂▇▄▃ - ▅▄▁▇▆▃▂ ▂▄▄▇▆▃▁▇▆ - ▁▁▅▂▅▂ ▆▁▃█▅█▁▃ ▁▃▆ - ▁▃▅▇█ ▂▁▇▁▅ ▅▅ █▅▇▁ - ▃▄▁▁█ ▅▇▃▅▁▇▆▂ ▅▅▅▇▁ - ▁▁▁▁ ▁▄█▃▁█▆ ▁ ▁▁▁ - ▁▇▁▃▆▄▄▄▄▄▄▁▁▁▁▇▇▄▇▁▄ ▃ █▁▁ - ▃▄▅▆▁▄▇▅▃▇▇▃▆▂▆ ▃▂ ▅▃▂▃▆▅▅▁▂ - █▃█▅▇▁█████▇▇█ ▃▃▂▅▁▅▅ ▅▅█ - ▃▃▃▇▂▃ ▂▅▅▂▅▅█ - █ ▂▃▁▁▇▆ ▆▄▄▅▅▁▄▂ - ██▅▁▇▅▁▇▂▂▄▄▅▇█ ▄▇▁█ - █▁▅▂▅▆█▂▆▅▃▇▆▃ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_2.txt b/codex-rs/tui2/frames/hbars/frame_2.txt deleted file mode 100644 index d4efa4def0..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▂▅▄▇▄▄▄▇▆▂ - ▂▇▆▅▇▄▇▁▁▁▄▁▁▄▂█▁▇▇▂ - ▆▁▇▁▃▇▇▁▇▂▂ ▂█▇▄▇▅▁▁▄▁▆ - ▂▁▅▂▅▇█▆▆▂ ▆▆▃▁▅▁▆ - ▆▃█▅▇▃▁▃▂▃▃▂ ▃▆▁▃▁▆ - █▅▇█▂ ▁▇▃▇▅▃▇ ▃▃▁▇▁ - ▆▁█▇▁ ▃▅▁▄▁▂▁▆ ▅█▁█▁ - ▁█ ▃▂ ▆▁▇▁▁▄▇ ▁█▅▅▁ - ▇██▄▁ ▄▁▃▃▂▁█▅▁▇▇▇▇▇▇▇▇▆▁▂▁▁▇ - ▂▄█▁ ▅▁▁█ ▅▇ ▁▄▃▂▂▂▂▅▅▂▂▁▇▅ ▁ - ▃▃▃ ▇ ▇▃▅▅▁█ ▂█▇▇▇▇▇▇▇█▁▆▇▁▁ - █▃▂▅▅▆ ▂ ▆▇▄▅▆▇ - ▅▃▂▂▁▇▆ ▆▄▂▇ ▂▁█ - ▂▁▃▄▂▂█▇▇▅▄▄▄▄▅▄▇▂▂▁▁▇ - ▂▇▇▇▇▁▁▂▂▂▂▁▄▅▇█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_20.txt b/codex-rs/tui2/frames/hbars/frame_20.txt deleted file mode 100644 index 30c29f51c9..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▇▄▁▁▄▇▄▆▂▂ - ▆▄▄█▄▆▂▆▄▄▄▁▁▂▃▇▃▇▆ - ▆▆█▂▇▇█▁▇█ ▂█▃█▃▇▇▁▇▂ - ▂▁█▂▇▁▇ ▂▆▇▁▃▅▇▃▃ - ▂▁ ▆▁▇ ▂▅ ▂▁▁▃▇▃▁▁▃ - ▅▂▆▁█ ▇▇█▄▁█▅ █▃▁▁▃ - ▄▁▄▇▁ ▆▇ ▅▇▇▃ ▆▃▁▅ - ▁▂▃▅▂ ██▄▅▁█▆ ▁▁█▁ - ▁▅▁▁▄▆▄▄▄▄▄▄▄▄▂▆█▄▃▃▃▃▇ ▅ ▁▃ - █▁▃▁▂█ ▅▂▂▂▂▂▂▁▃ ▇▃▃█▁▄▃ ▆▅ ▁▁ - ▃▆▃▃████████▇█ ▃▆▄▁▁▆▁ ▅▃ - ▇ ▃▇▅▆ ▂▅▇▅▁▅ - ▃▂▃▇▆▁▂ ▆▄▇▂▄▇█ - ▃▆▆▅█▁▇▃▄▄▆▅█▁█▂▆▅▇█ - ▂▇▇▁▂ ▄▂▂▂▂▁▇██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_21.txt b/codex-rs/tui2/frames/hbars/frame_21.txt deleted file mode 100644 index b6a6c2c109..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▆▄▇▄▄▇▄▇▆▂▂ - ▆▇▇▂▂▂▂▇▁▄▁▇▄▂▂▆▇▇▂ - ▄▇▃▂▇▇▃█▂ ▂▃▁▇▇▂▂▇▆ - ▆▇▁▄██ ▂▆▄▇▆▄▇▆ - ▅▆▅▇▂ ▆▁▅▂▃ ▃▃▂▃▆ - ▅█▅▁ ▆▇▇▂▅▅ ▃▃▃▆ - ▁█▁▂ ▂ ▅▂▆▅█ ▃▄▃█ - ▂▃ ▃▄ █▁▆ ▁ ▁ - ▁ ▁ ▆▇▄▄▄▄▄▇▇▇ ▃▁▆█▁▆ ▄▅ ▁ - ▁ ▁▃ ▁▂ ▂▆▁ ▃█▂▇▁▆ ▅█▅▅ - ▄ █▅ ▂▂███ █▂ █▇▂▆▄ ▅▂ ▆ - █▇ ▅▄▂ ▂▇█ ▅ - ▇▂ ▃▆▂ ▂▅▄▇█ ▆▂ - ▇▄▂ ▇█▇▇▅▁▁▄▅▄▇██ ▆▂ - ▇▇▇▄▇▂▂▂▂▆▂▄▇▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_22.txt b/codex-rs/tui2/frames/hbars/frame_22.txt deleted file mode 100644 index 38195cd38b..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▄▇▄▄▇▄▇▆▂ - ▂▇▅▇▁▅▂▅▁▅▃▁▁▄▁▇▅▇▇▂ - ▆▄▇▄▁▃▇▇▂ ▂█▇▃▇▇▇▂▂▇▆ - ▄▇▁▇▇█ ▆▆▁▁▁▂▇▃ - ▃▁▁▅█ ▆▂▅▁▄▁▅▅▄█▁ - ▃▁▇▅▂ ▆▁▅▁▄▃▅▂ ▃▁▃▄▁ - ▁▇▇▃▅ ▁▇▁▃▇▅█ ▁▁▅▁ - ▁▁▄▁▂ ▁▃▁▁▃▃ ▁▃▁█▁ - ▁▁▆█▆ ▇▁▄▄▄▄▄▄▇▇▂▃▇▁▂ ▃▆ ▂▁▄█ - ▃▁▄ ▇▂▃▃▆ ▅▆█▁▅▅▁ ▃▇▄▂█▁▆▆ ▅▃▄ - ▃▃▃▂▁ █████▇▇█▂ ▂▅▇▇▂▅▅▅█▆▂ - ▃▇▃▂▃▆ ▂▇▆█▁▆█ - ██▇▄ ▇▇▂ ▂▅▇▆█▇▂▅ - █▅▇▇▆█▇▁▄▄▇▄▄▅▄▇███▆▇ - █▁█▁▂▄▂▂▂▆▄▂▅▄▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_23.txt b/codex-rs/tui2/frames/hbars/frame_23.txt deleted file mode 100644 index a81cac3ef2..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▂▄▇▇▇▇▄▇▂▂ - ▂▄▁▇▁▃▂▆▂▄▄▆▁▂▁▇▁▇▂ - ▆█▇▁█▆▄▇▂ █▁▅▇▁▃▄▁▁▆ - ▅▄▁▇▄▅▂ ▂▄▄▃▁▁▄▃ - ▅▅▁▂▅█ ▆▅▆▆█▇▅▇▃▁▃ - ▆▃▁█▅▂ ▂▇▇▇██▅▇█▁▁▃▁▆ - ▂▁▁█▁ ▅▅▅▅ ▂▁▂ █ ▁█▇ - ▁▅▁ ▁ ▁█▁▆▂▃▆ ▁ ▇▅▁ - ▃█▃ ▁▇▁▄▄▄▄▄▄▄▄▁▂▇▁▂█▃▂ ▁ ▁▁▁ - ▁▃▃▂▁█▄█ ▅▂▃ ▃▁▂▃▆▁ ▃▂▁ - ▅█▁ ▁ ▂███████ █▂▅▄▄▁▂▅▃▅▂ - ▆▁▁▂▁▅ ▆▅▁▃▃▅▂ - ▃ ▁▆█▅▂▂ ▂▅█▄▇▅▇▅ - ▃▄▃▇▄▇▇▃▁▄▄▂▇▇█▆▂▇▇▂ - █▁ ▇▄▃▁▂▂▇▂▂▇▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_24.txt b/codex-rs/tui2/frames/hbars/frame_24.txt deleted file mode 100644 index 791f93b591..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▁▂▄▇▇▇▄▄▆▂ - ▆█▂▅█▁▂▂▁▄▄▇▆▂█▇ - ▆██▄▃▅▇█▁ ▅▂▅▇▁▇▇▆ ▃ - ▆█▅▅▆▅▁▄▆▃▂▁▅▂ ▂▇▃▁▃▂█ - ▆ ▅▅▃▅▆ ▂██▅▃▂▁▂▂▃▁▆█ - ▁▆▃▇▃▂ ▇▆▆▅ ▅▁▄ ▅█▃ - ▁ ▁▁ ▆▅▂▆▅█▅▃▃▁▃▃▁▃ - ▁ ▃▁▁ ▁▆▄▅▃▂▁▁▂▅▅▂▁▁ - ▁▅ ▁▄▄▄▅▇▇▁▁▁▇▇▂▇▆▂▁ ▁ ▁▁ - ▂ ▅ ▇▁▅▆▆▄▄▆▆▁▅▃▃▇▇▁ ▁▇▁ - ▃ █▃▁▃▇██████▂ ▃▆▅▇▃▁▄▅▆▂ - ▃ ▃▆▇▃ ▁ █▅▄▅ - ▃▅▇▃▂▁▅ ▂▄█▂▅▅▂▇ - ▅▆▇▆▃▃█▁▁▅▄▄▄▁▃▂▅▂ - ▇▅▂▇▇▄▃▂▂▄▄▇▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_25.txt b/codex-rs/tui2/frames/hbars/frame_25.txt deleted file mode 100644 index 565fdb82ea..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▇▄▇▄▇▇▇▇▂ - ▆ ▇▆▇▂▂▄▃▁▇▁▁▆ - ▅▁▅▅▆▆█▅▁▇▃▄▃▁▁▁▂ - ▁▇▇▁ ▅ ▄▁ █▃▁▆▇▃▅ - ▆▂▂▃▆▅█▃▄▅▂▁█ ▁▅▅▃▅▇ - ▃▆▁▁▁▂▇▇▅▃▁▅ ▅▁▆▁▅▅▁▆ - ▁▅▁▇▁ ▁▂▂▅▃▂▅▁▃▅▂█▁▄▁ - ▁▁▁▅▁▅█ ▁▂▄▃▅▁▃▂▁▄▁▁▃ - ▃▇▃▁█▁▂▂▄▄▄▄▇▁▁▃▃▁▇▇▁▃ - ▅▆█▁▅▁▆▁▂▁▁▄▇▂▁▃▇▄▅▃▁ - ▃▁▇▅▃▁▂██████▇▅▁▃▁▅▁▂ - ▁▂█▁▄▇▄▃▆▆▇▅▂▅▆▅▅▁▅ - ▃▂▃▁▂▃▂▇▄▅▆▃▅▂▁▁▅ - ▃▅▄▃▃▂▇▄▁▇▇ ▇▁▇ - ▁▆█▇▃▇▆▂▇▁▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_26.txt b/codex-rs/tui2/frames/hbars/frame_26.txt deleted file mode 100644 index e37d671dc4..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄▇▇▇█▇▆ - ▅▇▁▄▅▃▁▇▄▁▆ - ▅▇██▁▄▃▁▃██▃▆ - ▄▇█▆▁▁▁▂▁▁▂▇▅▃ - ▁▂▆▃▁▅▁█▇▃▅▄▁▃▄ - ▃ █▁▅▅▅█▇▂▃█▃▃▅ - ▁ ▇█▄▁▁▅▁▁▄▄█▇ - ▅▆ ▃▁▁▁▃▃▄▁▁▅▁▁ - ▁ ▅▃▇▄▇▁▇▄▄▅▃▁▁ - ▅▂▁▅▆▁▂▂▆▇▃▃▇▁ - ▁▂█▅▁▇█▃▆▅▂▇▃▁▁ - ▃▂▁▁▅▃ ▅▅█▁▁▂ - ▇ ▇▁▃ ▃▂▅▁▅▆ - ▁█▇▃▃█▇▂▇▇▄ - ▃▅▅▃▇▁▂▃▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_27.txt b/codex-rs/tui2/frames/hbars/frame_27.txt deleted file mode 100644 index d3dbefa975..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▄▇▇▇▇▆ - ▁▄▅▅▁▅▃ - ▄█▂ ▇▁▅█▁ - ▁▁▁▃▁▁▄▁▁ - ▁ ▂▁▂▆█▄ - ▁▂▅▂█▂▁▁▁▂ - ▄ █▁▁▂▇▁▁ - ▁ ▂▁▁▁▄█▁▁ - ▁▅ ▅ ▁▇▁▁▁▁ - █▄ ▅█▂▁▂▇▁▁ - ▁▆ ▂▃▇▇▇▅█ - ▁▁▇▇▁▁▂▅▁ - ▁▄▄▇▄▆▅▁▃ - ▃▂▂▁▃▄▅▅ - █▃█ ▃▃▅ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_28.txt b/codex-rs/tui2/frames/hbars/frame_28.txt deleted file mode 100644 index 0ae0f54e0b..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▅▇▇▄ - ▁▇▂▁▂ - ▄ ▄▁▂ - ▁▆▂▇▁ - ▁ ▁▁ - ▁ ▆█▁ - ▃▁▁ ▂▁ - ▁▆██▁ - ▅▃ ▂▁ - ▁▁▅▅▁▁ - ▁ ▂▁ - ▁▄▇▄▁ - ▁▄▇▁▁ - ▇▂▂▅▁ - ▁ ▅▇▁ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_29.txt b/codex-rs/tui2/frames/hbars/frame_29.txt deleted file mode 100644 index d333f278dc..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▇▇▇▇▇▆ - ▆▁▇▅ ▇▃ - ▃▆▁▁ ██▁ - ▁▁▁▁▃▃▆▅ - ▃ ▁█▁ ▁ - ▁▆▁▁▁ ▂▂ - ▁▃▃▁▁▁ ▃ - ▁▁▄▁▁ - ▁▁▁▇▁ ▂ - ▂▁▄▁▁ ▂ - ▇▁▁▅▁▃ ▄ - ▁▅▁▁▁ ▂▁ - ▁▁▁▁▂▁▂▁ - ▁▄▁▇▅█▁█ - ▁▁▇ ▅▆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_3.txt b/codex-rs/tui2/frames/hbars/frame_3.txt deleted file mode 100644 index 5d0b07202a..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▇▇▄▄▄▄▆▂ - ▂▇▆▅▃▁▁▇▁█▄▄▁▄▂▃▁▇▇▂ - ▆▄▅▇▄▁▇██▂ ▂█▁ ▇▇▇▅▃▁▆ - ▅▇▅▃▁▄▆▇▂ ▂▄▇ ▁▁▃ - █▅▇▁▅▃▅▁▁▇▃▂ ▃▃▃▁▁▂ - ▅▁▃▃▅ ▅▆▁▃▄▃▃ ▁ ▃▃▁ - ▄▇ ▁ █▇▃▁▂█▁▆ ▅▇▅▁▆ - ▆▆▁▆▁ ▆▆▁█▅▃▁ ▁▂▁▄▁ - ▆██▁ ▄▁▇▇▁▁▇▆▁▄▇▇▂▂▂▇▇▂▁▃█▄▁ - ▆▃▅▃▆ ▅▅▇▅▄▄▁ ▁▇▁▂▂▆ ▄▅▆▄▃▂▁▃▁▂ - █▃▁ ▁ █▁▁▇▆█ █▇▇▇███▇▇▆▅▅▅▆▅ - █▇▁ ▃▇ ▂ ▅▃▇▃▅▅ - ▄▃▅▅▇▃▆ ▆▇▄▅█▆▁█ - ▄▃▇▆▂▇▇▃▄▄▄▄▆▅▄▇▄▂▂▇█ - ▂▇▇▇▂▅▁▂▂▂▂▁▄▅▃█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_30.txt b/codex-rs/tui2/frames/hbars/frame_30.txt deleted file mode 100644 index 7ceb36d37a..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▄█▇▇▇▆ - ▂▅▄▁▁▃▇█▄▃ - ██▃▁▆▃▃▁▇▆▃ - ▁▁▃▁▅▁▂▃▁▃ ▃▆ - ▄▇▁▁▂▃▁▆▁▆ ▁ - ▁▇▁▃▇ ▇▁▁▁▁▅▁ - ▃▅▁▁▁▃▂ ▃▅▃▁ ▄▂ - █▁ █▇▄▁▆▁█▁▆ ▂ - █▁▂█▃▁▄▁▃▅▁▆ ▂ - █▁▁▃▁▂▂▁▁ ▇▆ ▂ - ▃▁▁▄▃▂▇▁▁▁▇▁▁ - ▁▇▅▅▆▅▇▃▁▁█ ▁ - ▃▁▃▁▁▅▇▁▁▆▁▅ - ▆▃▆▇▄▃▅▁▆▅█ - ▃▆▅▁▃▂▂▄█ - ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_31.txt b/codex-rs/tui2/frames/hbars/frame_31.txt deleted file mode 100644 index 419be30ed9..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▅▇▇▇▇▄▆ - ▂▇▇▄▇▆▂█▇▃█▇▆ - ▆▁▃▅▅▆▄▂▂▅█▁▂▇▃ - ▆▁▅▇▂▂▇▄▂▆▃▃▁▃▁▁▃ - ▃▁▁▁▁▇▆▇▃▂▆█▃▁▅▂▂ - ▄▁▃▂▁▂▁██▃▄▇▆▅▅▁▆█▁ - ▅▁▁ ▁▃▇▁▃▅▄▅▄▇▁▇▁▅▁ - ▃▃▆ ▁▁█▁▃ ▁▃██▁▁▃▅▁ - ▁▃▆▂▁█▃▁▁▁▅▇▁▁▁▂ ▁ - █▁▁▁▅▅▁▁▂▂▂▃▂▁▃▁▁ ▇ - ▃▇▂▅▇▁▆ ▅▇▇▇▂▅▁█▁▂ - ▁▁▁▃▃▃▆ ▂▄▇ ▁ - ▄▁▇▄▂ ▆ ▅ ▁▇▂▅▂ - ▅▁▃▁▅▂▁▂▆▁█▆▁▂ - ██▄▁▂▇▇▅▅▄▇ - ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_32.txt b/codex-rs/tui2/frames/hbars/frame_32.txt deleted file mode 100644 index 1234a419b0..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▆▇▇▇▇▄▄▁▆▂ - ▄▇▁▇▇▁▃▁▁▇▇▁▁▆ - ▅▃▇▄▃▆▇▃▅▆▂▇▁▇▃█▇▆ - █▅▅▁▂▆▂▄▆ ▆ ▆▂▃▁▇▆ ▆ - ▅▅▁▆▁▇▃█▇▁▄ █▂▆▁▃▅▃▇▃▆ - ▃▅▃▃▆▃▇▁▂▅▁▁▇▂ ▁▇▅ ▇ - ▇▁█▄▆▂█▃▁▁▆▆▁▆▄▂▅▂▅▅▁█▂ - ▁▁▁▆▂ █▂▁▁█▅█▁▄▆▂█▃▁██▂ - ▃▁▁▆▂ █▂▁█▂▂▁▇▇▇▁▁▁▄▁█▄▂ - ███▂▂▁▇▃█▃▂▂▂▂▂▁▁▇▁▄▁▆▂ - ▆▃▇▁▁▅ ▁▇██▇▇▇▃▄▅▅▅▅▅ - ▃▆▁▃▃▃ ▆▃▁▅█▁ - █▇▇▁▁▁▇▂ ▂▅▂▁▅ ▆ - ▃▆▃█▁▇▁▄▄▇▂▂▇█▂▅ - ▁▄▄▂▂▆▇▅▇▂██ - ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_33.txt b/codex-rs/tui2/frames/hbars/frame_33.txt deleted file mode 100644 index 780eb104ef..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▄▇▄▇▄▁▅▂ - ▂▇▇▁▁▃▇▄▁▅▁▆▁▇▁▇▅ - ▂▅▃▁▇▇▇▆▄█▂ █▇▅▁▁▇█▆ - ▆▃▅▁▅▂▁▆▂ ▇▁▁▃▂▇ - ▂▅▁▅▂▁▁▅▃▃▃ ▁▇▃▃▃ - ▅▅▆ ▃▂▃▃▂▃▆ ▅▃▅▂▃ - ▁▁▇ ▃▆▇▁▂▁▃ ▁▇▁▁ - ▇▁▆▆ ▆██▃▅▄▆ ▃█▁▇ - ▁▁▁ ▁ ▆▂ ▅▇▆▆▆▄▄▄▇▁▁▇▅▂▁▃▁ - ▃▅▁██▅▅▆▅▂▁▁▂▂▂▂▂▁▁▇▃▃▃▁▁▅ - ▃▄▃▆ ▁▅▁▅ ▅ ████▇▇▇ ▁▁▆▅▃▂ - ▃▇▃▇▆█▆ ▅▅▄▅▄▅ - ▇▃█▇▆▇▄▆ ▂▇▁▆▅▇▃█ - ▃▁█▇▃▇▁▆▄▇▂█▄▆▃▇▁▇ - █▄▄▇▆▂▂▂▃▇▇▄▆█ - ▂▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_34.txt b/codex-rs/tui2/frames/hbars/frame_34.txt deleted file mode 100644 index 4bf69e69eb..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▄▇▇▇▇▄▁▆▂ - ▂▇▇▇▅▃▄▁█▁▇▇▄▇▇▄▆▄▂ - ▂▅▅▆▁▇█▅▁▇▂ ▂▃▃▃▆█▇▇▆ - ▆▅▂▁▇▂▅▆▂ ▇▁▆▇▃▃ - ▅▅▁▃▆▅▁ ▁█ ▆ ▃▃▇▃▃ - ▆▅▃▁▂▅ ▃▃█▁▃▇▃ ▃▆▃▁▃ - ▁▇▃▁▂ █▃▂▇▁▂▃ ▁▄▁▂ - ▃▁▄█▅ ▁▇▂▁▅█▁ ▅ ▂▃ - ▆█▅▅█ ▅▃▅▅█▂▄▄▇▇▇▇▇▇▁▂▂█ ▁▃▂ - ▁▁▃▆▅▆▆▃█▅▅█▁▁▁▂▁▅▂▂▂▆▅▂▁▁▂▁▃ - ▅▂▃▆ ▁▃▁▇▃ █▇▇▂▂▂▂▃ ▆▅▆▃▁█ - ▅▆▃▄▄▃▂ ▅▄▅▁▅█ - ▃▇▂▁▇▃▄▆ ▂▇▄█▂▅▆▂ - ▆▃▅▁█▁▇▆▄▆▇▄▇▄▇▂▇▇▅█ - █▁▃▄▄▂▂▂▂▂▄▄▇▄▃ - ▂▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_35.txt b/codex-rs/tui2/frames/hbars/frame_35.txt deleted file mode 100644 index 86dde2ad34..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▆▇▄▄▇▇▇▄▇▆▂ - ▆▇▄▇▅▁▇▇▇█▁▇▁▅█▁▇▄▂ - ▂▅▅▁▄▇▇▁▃▇█▂ ▂█▇▄▇▅█▁▇▆ - ▆▅▅▇▅▆▄▆▂ ▇▇▂▃▁▂ - ▅▅▁▁▅▅▃ █▃▇▆ ▁▃▇▁▆ - ▄▅▄▅▇▂ █▁▂█▅▅▇▂ ▁▃▃▁ - ▃ ▅▅ ▃▃ █▇▂▅ ▁▃▃▃ - ▁▁▁▅ ▅▃▃▄▅█ ▅ █▅ - ▅ ▆▂ ▆▁▁▅▄█▅▁▄▄▂▄▂▄▇▁▁ ▁▁▄▇ - ▁▆▃▄█ ▅▅▄█▇▇▅▁▄ ▃▆▂▂▂▂▅█▁▅█ ▁█ - ▅▂▃▆█▅▃▂▁▅▃▂ ▂█▃▃▃▃▇▃▃▇▅▅▂▆▁ - █▅▃▃▅▁ ▂▅▇ ▆▃ - ▃▄▂▃▄▆▅▆ ▆▄▅▂▇▇█ - █▃▂█▁▇▁▆▅▁▁▇▁▄▅▇█▂▅▄█ - █▃▁▄▄▂▂▅▂▂▇▇▇█▃ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_36.txt b/codex-rs/tui2/frames/hbars/frame_36.txt deleted file mode 100644 index bccadcf7b7..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▇▄▇▇▇▇▄▇▆▂ - ▂▄██▃▇▅▄▅▇▃▇▄▇▇█▇▇▇▂ - ▆▃█▃▆▇▇▆▇█▂▂ ▂█▇▁▇▇▁▆▇▁▆ - ▂▁█▇▇▂▂▂▂ █▇▁▃█▁ - ▆▅▂▄▃█▁▃▂▃▆▅ ▃▃▅▇ - ▅ ▆▅▂ ▇▂ █▁▅▆ █▃▃▇ - ▁ ▄ ▂ ▃ █▃▃ ▁▃▁▆ - ▁▁▃▁ ▂▃ ▂▅▅ ▃▁ ▁ - ▁██▁ ▅▅ ▆▇██▆▇▇▂▇▇▇▇▇▂ ▁▃▁▁ - ▂▇▁▃ ▆▁▂▂▅▁▅ ▂▁▂▂▂▂▂▂▂▁▁ ▃▅█▂ - █ ▇▁▃ ▇▂▇▅▃ █▇▇▃▃▃▃▃█ ▁▆█▅ - █▆█▃▄▅ ▆▅▁█▅ - ▃▅▁▁▇▁▆ ▆▇▇█▅▆▂ - ▇▆▂█▇▇▄▃▄▁▂▇▁▄█▆█▆▄▇█ - ▂▇▇▅▁▂▆▆▂▆▆▇▇▇▇█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_4.txt b/codex-rs/tui2/frames/hbars/frame_4.txt deleted file mode 100644 index 5867215a96..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▇▄▄▇▄▇▆▂ - ▂▆█▇▇▁▅ ▁▃▄▅▇▄▁█▅▇▆▂ - ▆▇▄▁▁▁▇▇█▂ ▂▃▁▃▃▇▁▅▃▃▂ - ▅▅▁▆▁█▅▇▆ █▁▇▇▃▇▃ - ▅▁▅▂▁▄▁▃▄▃▁▆ ▃█▃▁▇▃ - ▅▁▁▃▁ █▂▁▁▅▇▃ █ ▃▄▁▆ - █▃▁▄▂ █▇▇▃▇█▁▆ ▆ █▄▁ - ▁▅▃ ▆▁▅▇▃▁▁ ▁▁▁ - ▂▁ ▁▂ ▄▁▅█▃▅▅▇▁▇▇▇▂▂▂▂▆▆ ▁▁▇ - ▁▃▅ ▁ ▆▅▅▅▆▂▁▂▄▃▁▆▅▂▆▅▅▃▄▁▅▅█▁ - ▂▁▄█▃▆▃▃▆▃▅ █▇▇▇▇▇▇▇█▃▆▁▁▁▂ - ▆▁▃█▁▂▂ ▆▃▄▅▇▇█ - ▃▃▆▅▃ ▆▂ ▂▅▃▆▇▄▁▅ - ▁▁▁▄▂▇▇▃▄▄▄▄▆▇▄▇▇▃▁▇▂ - ▇▃▃▁▅▁▂▂▂▂▂▄▆▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_5.txt b/codex-rs/tui2/frames/hbars/frame_5.txt deleted file mode 100644 index d0cd750b8a..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▄▇▄▄▄▇▆▂ - ▂▇█▇▅▇▅▁▁▇▁▄▇▄▂▁█▇▆ - ▇▂▅▃▇▁▇█▂ █▃▄▁▇▃ ▆▇▆ - ▆▁▅▁▅▁▅▄▆ ▆▃▁▁▁▁▂ - ▆▃▅▇▅▃▂▇▁▂▃▆ ▂ ▃▁▃▁▂ - ▁▁▁▁▅ ▃▂▁▃▄▅▁▂ ▇▅▁▁ - ▁▅▁ ▇ ▂▃▂▁▃█▁▆ ██▁▇▁▆ - ▁▅▁▂█ ▆▂▄▁▅▂▁ ▇▁▂▁ - ▁▆▁▃▇ ▅▂▁█▆▁▇▄▂▁▇▁▂▂▂▇▆▂█▁▅▁ - ▂▃▁▃▆▆▅▇▇█ ▁█ ▅▅▃▄▃▆▄▅▆▅▁▅▇▁▂ - ▃▆▁▁▆▁▆▃▃▅▇▂ █▇▇█████▆▁▄▁▃▅ - ▃▃▇▅▃▃ ▂ ▅▂▅▇▆▃ - ▃▅▇▃▁▄▇▂ ▂▄▇▆▇█▄▇ - ▃▁▇▁▆▇▇▃▄▁▄▂▆▇▄▇▃▁▄▇ - ▃▆▇▂▇█▂▂▂▆▂▆▇▇▂ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_6.txt b/codex-rs/tui2/frames/hbars/frame_6.txt deleted file mode 100644 index 2fde73afab..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▄▄▄▇▄▄▇▇▂▂ - ▆▃▄▅▇▁▄▇▇▁▇▇▄▂▆▇▇▂ - ▆▆▆▁▁▁▇█ ▂█▁▅▄▇▇▇▅▃▂ - ▅▆▁▇▁▇▂▄▆ ▃▇▁▁▁▁▆ - █▄▁▁▁▇▃▃▇▇▃ ▅▃▃▁▃▆ - ▄▆▅▁▅▂▃▇▄▁▃▂▁▆ ▅▄▃▅▁ - ▂ ▇▁▁ ▃▃▁▅▃▃ ▃▇▁▂▁ - ▁ ▁▁▁ ▂▁▄▁▅▁▁ ▁▇▃▁▁ - ▂▅▁▆▁ ▅█▁▁▇▄▅▇▂▁▇▁▂▂▇▇▂▅▃▅▁ - ▁▁▃▆▅▂▃▂▄▇ ▁▅▄▇▆▁▁▆▄▄▄▃▁▆▁▃ - ▅ ▁ ▅▁▁▁▄▅█ █▇████▇█▂▃▃▅▁ - ▆ ▄ ▅▆▂ ▆▅▂▅▅▁▂ - ▅▂▃▂▇▇▆ ▂▇▃▂▅▇▆▇ - ▂█▁▇▆█▇ ▄▁▂▂▆▇▇▇ ▅▄█ - ▃▆▄▇▇▆▂▂▂▂▂▆▇▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_7.txt b/codex-rs/tui2/frames/hbars/frame_7.txt deleted file mode 100644 index f9b4ed9219..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▅▁▄▇▇▄▄▇▆▂ - ▆▇▂▇▃▁▇▇▇▄▄▃▄ ▂▇▆ - ▇ ▆▁▁▁▇▂ ▂▃▁▂ ▇▅▂▇▆ - ▃▃▁▁▁▁▄▇ ▃▁▃▇▁▅▆ - ▂█▁▇▁▁▃▁▅▃▆ ▅ ▇▁▁▃ - ▁ ▄▁▁█ ▂█▃▄▁▃ ▆▆▁▁▁ - ▂▃█▁ ▃▂▁▃▁▃▁ ▇▇▃▁▁ - ▂█▅▁▁ ▄▇▅▁▇▁▂ █ ▁▁▁ - ▅█▁▇ ▆█▂▅▁▃▅▁▁▄▄▂▁▁▄▄▇▃▁▁ - ▁▆█▁▁▄▅▁▁▅█▁▅▃▁▁▃▃▆▄▆▄▃▁▃▁ - ▃ ▄▃▅▇▁▇▂▅ ▇▇▇▇▇▇██▂▅▁▁ - █▂ ▅▂▅▂▂ ▆█▆▅▄▅█ - █▄▆▇▃▃▃▂ ▆▃▁▆▇▅▇█ - ▃▂█▃▁▇▃▄▁▂▂▂▇▇▁▇▇▅ - █▁▇█▁▅▁▂▂▆▂▄▇▇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_8.txt b/codex-rs/tui2/frames/hbars/frame_8.txt deleted file mode 100644 index 44c448de8a..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▂▂▇▂▄▇▇▄▇▆▂ - ▆█▇▅▁▁▁▇▄▄ ▁ █▇ - ▂▁▄▆▁▅▅▇ ▆█▁▅▁▇▃▇▃▆ - ▂▇▃▇▅▁▆▇▁ █ ▄▃▁▁▃ - █ ▅▁▅▆▃▁▅▃ ▅█▃▁▁▆ - ▁█▆▁▃▃▃▂▃▁▂▇ ▃▇▁▁▁ - ▁▇▁▁▁ █▂▄▃▁▂▁ ▁▃▁▅▁▆ - ▃▆▁▁▅ ▁▇▃▅▁▆▇▅ ▅▂▅▁▁ - ▃ ▃▁▁▂▃ ▅▇▄▁▁▅▇▆▇▆▆▃▁▁▁ - ▃▇▅▄▂▁█▆▁▆▁▁▄▁▄▂▂▁▁▄█ - ▅▅▃▃▁▃▁▁▁▅█▃▇▃▇█▃▇█▅▃▁ - █▆█▁▃▁▂▂ ▅▆▅▅▅█ - █▃▂▇▃▃▃▂ ▂▅▃▂▁▇▅▇ - ▅▅█▁▆▇ ▄▄▇▇▄▅▄▆▁▂ - ▁▁▂▇▇▃▁▂▆▇▅▄▂ - ▂▂ \ No newline at end of file diff --git a/codex-rs/tui2/frames/hbars/frame_9.txt b/codex-rs/tui2/frames/hbars/frame_9.txt deleted file mode 100644 index a18a8a231c..0000000000 --- a/codex-rs/tui2/frames/hbars/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▅▄▃▇▅▇▇▄▆ - ▅▇▂▅▁▁▇█▄ ▂▆▃▂ - ▅ ▆▁▁▅▇▃▃▄▅ ▃▂▁▆ - █ ▁▁▅▄▄▂ ▂▃▆▁▃▃▁▇▆ - ▅▇█▄▃▇█▁▇▆ ▇▅█▇▅▇▁▁ - ▁ ▁▁▁█▃▁▃▄▂▂ ▁█▁▇▁▁▁ - ▂ ▁▇▃▁▇▇▁▃▃▆█▅▆▅ ▂▁▁ - ▁ ▁▅▂▆▃▁▁▁▂▅▄▃▂▁ ▁▁ - █▆▁█ ▅▁█▁▁▁▁▇▁▁▆▁▁▂ - ▁▂▁▁▁▂▆▁▃▁▂▁▁▁▁▂▂▁▁▁ - █ ▂▃▅▃▃▁▅█▇▇▇▇▁▁█▅▃▁ - ▃▁▁▁▃▆▂ ▆▅ ▃▅▁▂ - ▃▇▃▁▃▃ ▅▄▂▄▁▅▇ - ▃▆█▃▂▁▇▅▇▄▄▄▇▁▂ - █▆▂ ▇▃▃▁▄▇▅█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_1.txt b/codex-rs/tui2/frames/openai/frame_1.txt deleted file mode 100644 index 1019a11c95..0000000000 --- a/codex-rs/tui2/frames/openai/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeaenppnnppa - anpeonpepnniina aopa - pioipoooaa aooaoiniiip - noanooppa eoinip - naneoainann oeinnp - io pa ioeniip oeniip - paopo onioeoia iei - iiaia peeinio oai - inioa niianioeippppppapp no i - aino anpa eo ioiaaaeepppiepepi - naaoa anpeea aaoooo aooepnae - oap op a poeoai - anpanpa anapiapo - aopna opennnnenopapio - aoooiiiaaapanpoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_10.txt b/codex-rs/tui2/frames/openai/frame_10.txt deleted file mode 100644 index 942f59e944..0000000000 --- a/codex-rs/tui2/frames/openai/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - apooonppa - ooapopnieaop - aapiieiipipnpp - iaaeninaenpoonnp - e niioia opeaeie - ia oioieen aaoi - n ioooiiiio iep - o iinpepiipaaenii - o in nniniipnpnii - i pinpiaeaoaaaioa - p oiiiiioo nponia - naopnpoo aioopee - io iiiiopepeiea - naooeipna eeo - op aonapno - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_11.txt b/codex-rs/tui2/frames/openai/frame_11.txt deleted file mode 100644 index ef0aff76e0..0000000000 --- a/codex-rs/tui2/frames/openai/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pooooppa - eo niiinnp - eoaaiinnoenp - iaaiinnpanee - aoennipnonaiip - ipaiipanniaeii - oo piiiiiiainn - i iieiine ioi - e oieiioepiai - oo aienpoeiaia - oap iiiineinni - napeiaoeoonia - a oinnaaino - np oiipaeoa - na oopapa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_12.txt b/codex-rs/tui2/frames/openai/frame_12.txt deleted file mode 100644 index 8940e05bd6..0000000000 --- a/codex-rs/tui2/frames/openai/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pooope - pnaaeipn - o ienpp - epiiinnoi - i oiiiieei - ioeeoaoaio - i ieniin - i epniio - op ieiipii - i eionoa - ie ionooe - p oi api - e ooiponi - oaeopinia - n oinee - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_13.txt b/codex-rs/tui2/frames/openai/frame_13.txt deleted file mode 100644 index c73afab740..0000000000 --- a/codex-rs/tui2/frames/openai/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eooop - iaaii - iaaio - iooia - o ii - oepii - p ii - p oi - ep aie - io pni - oaee ia - iaaai - ippia - oaain - p eon - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_14.txt b/codex-rs/tui2/frames/openai/frame_14.txt deleted file mode 100644 index 8a273a1666..0000000000 --- a/codex-rs/tui2/frames/openai/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pooon - peaaen - iaiie i - iieioaip - iiin i - ioii oi - iiii e o - iinia n - iiii o - oaee ne - nioopepi - ioiiaaai - e oinnna - ineoaae - ao one - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_15.txt b/codex-rs/tui2/frames/openai/frame_15.txt deleted file mode 100644 index 5a0e8f1b54..0000000000 --- a/codex-rs/tui2/frames/openai/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ppoooia - apniiaeop - eeionniooi - iea eepeaai - iinonniieoai - niipiieei o - ii iiiiiipi - iieiipiii opi - iipppoiiiaeei - iaineinioe ei - ieiiiiai a n - iooeaiinp n - oinp eeea na - iaiinnepne - naoaaaae - aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_16.txt b/codex-rs/tui2/frames/openai/frame_16.txt deleted file mode 100644 index 06c519f602..0000000000 --- a/codex-rs/tui2/frames/openai/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - anpooopia - eaiioiininn - ieeonennii on - eee noaniniiooi - iinaeooniioeian - iiioio inn i iei - nni eapiiiieoop i - ien iaiieiiion ai - nniiipieaiioop e - ooaapnnnoieai pai - iiipooeoninoneiia - iio ia aeep e - nio i epeapi - niioaoieepai - oeeaneaano - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_17.txt b/codex-rs/tui2/frames/openai/frame_17.txt deleted file mode 100644 index 0bd4ef6dfc..0000000000 --- a/codex-rs/tui2/frames/openai/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pnpppnnipa - anooiiionoipoap - poneannappoiiiaon - eieaeioaa oinniiinn - iea i ioennnee p - anopea peei epneiee - o nei peeia ooiieni - poni eeiieipiinno - peoennnpinainaeniii i - ioanaaieoneiin npopn - nnneooooooonaioeen no - niiao aeiia n - ann ona popeaae - p iopnnpaanoeio - apneappioapo - a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_18.txt b/codex-rs/tui2/frames/openai/frame_18.txt deleted file mode 100644 index de59f344ef..0000000000 --- a/codex-rs/tui2/frames/openai/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - annpnnpnipa - apoanpppiaapopnna - aeeiooonopoeaopeen o - piioepaoo onaaeninnnnnp - anee ei aopaneieiannnna - eni ppieaaaepopiopeaeaei - iinee peoeioaeooininp - onpii anoiina inio - noo pnnnnnnpiipioenp ioia - anniono iieaanaiiianian p - ieinaoooooooa naaeieoeapa - nn pnp aaooeoao - naopaae aoaaenpa - oieooppnnnaooaeapo - oiiaeaapeponpo - a \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_19.txt b/codex-rs/tui2/frames/openai/frame_19.txt deleted file mode 100644 index ade5662359..0000000000 --- a/codex-rs/tui2/frames/openai/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aannpipnniea - apiiinennnaoapiopp - ponneoipoa aoepaonn - eniopaa annppaiop - iieaea piaoeoin inp - ineoo aioie ee oeoi - aniio eoaeippa eeepi - iiii inoniop i iii - ioinpnnnnnniiiiponoin n oii - anepinoeaopnpap aa enanpeeia - onoepioooooooo anaeiee eeo - nnaoan aeeaeeo - o aniiop pnneeina - ooeioeipaanneoo noio - oieaepoapeaopa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_2.txt b/codex-rs/tui2/frames/openai/frame_2.txt deleted file mode 100644 index be49360bbf..0000000000 --- a/codex-rs/tui2/frames/openai/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeaenpnnnppa - appeonpiiiniinaoiopa - pioinooioaa aoonoeiinip - aieaeooppa ppnieip - paoeoainanna apinip - oepoa ionpenp nnioi - piooi aeiniaip eoioi - io na pioiino ioeei - oooni niaaaioeipppppppppiaiio - anoi eiio eo innaaaaeeaaipe i - nnn o oneeio aoooooooooipoii - onaeep a ppnepo - enaaipp pnap aio - aianaaoopennnnenoaaiio - aopopiiaaaaineoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_20.txt b/codex-rs/tui2/frames/openai/frame_20.txt deleted file mode 100644 index 6eaf358e88..0000000000 --- a/codex-rs/tui2/frames/openai/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapniinpnpaa - pnnonpapnnniiaaonpp - ppoapooioo aoaonpoipa - aioaoio appineonn - ai pio ae aiiaoniin - eapio poonioe oniin - ninpi po epoa pnie - iaaea ooneiop iioi - ieiinpnnnnnnnnaponaanno e in - oiaiao eaaaaaain onnoinn pe ii - npanoooooooooo npniipi en - o aoep aeoeie - naaopia pnoanoo - appeoipannpeoioapeoo - aopia naaaaiooo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_21.txt b/codex-rs/tui2/frames/openai/frame_21.txt deleted file mode 100644 index 5f317f375c..0000000000 --- a/codex-rs/tui2/frames/openai/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapnpnnpnppaa - ppoaaaapinipnaapopa - noaapoaoa aaiopaaop - poinoo apnopnop - epeoa piean nnaap - eoei pooaee nnap - ioia a eapeo nnno - an nn oip i i - i i ppnnnnnppp aipoip ne i - i in ia api aoaoip eoee - n oe aaooo oa ooapn ea p - op ena aoo e - oa apa aenoo pa - ona ooopeiinenooo pa - oppnpaaaapanpoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_22.txt b/codex-rs/tui2/frames/openai/frame_22.txt deleted file mode 100644 index 74b75b9113..0000000000 --- a/codex-rs/tui2/frames/openai/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appnpnnpnppa - apeoieaeieniinipeopa - pnoninooa aoonoopaapp - noiooo ppiiiaon - niieo paeinieenoi - nioea pieinaea ninni - ipone ipinoeo iiei - iinia iniian iaioi - iipop pinnnnnnppanoia np aino - ain oannp epoieei nonaoipp enn - nnnai ooooooooa aeopaeeeopa - nonanp aopoipo - oopn ppa aeopooae - oeoppooinnpnnenoooopo - oioianaaapnaenoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_23.txt b/codex-rs/tui2/frames/openai/frame_23.txt deleted file mode 100644 index 35e7fe2210..0000000000 --- a/codex-rs/tui2/frames/openai/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appanpoppnpaa - anipinapannpiaioipa - popiopnoa oiepinniip - enionea annniinn - eeiaeo peppooeonin - pnioea aoooooeooiinip - aiioi eeee aia o ioo - iei i ioipanp i oei - aon ipinnnnnnnniaoiaona i iii - innaiono ean nianpi nai - eoi i aooooooo oaenniaeaea - piiaie peinaea - n ipoeaa aeonoepe - nnnpnopninnappopapoa - oi onniaapaaooa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_24.txt b/codex-rs/tui2/frames/openai/frame_24.txt deleted file mode 100644 index a74ea1f0bb..0000000000 --- a/codex-rs/tui2/frames/openai/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aianpponnpa - poaeoiaainnppaop - poonnepoi eaeoioop n - poeepeinpnaiea aoninao - p eenep aooenaiaanipo - ipnpna oppe ein eon - i ii peapeoeaninaia - i nii ipnenaiiaeeaii - ie innneppiiippaopai i ii - a e oieppnnppieanpoi ioi - n oninoooooooa npeoninepa - n npon i oene - neonaie anoaeeao - epopnaoiiennniaaea - oeaopnnaannpo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_25.txt b/codex-rs/tui2/frames/openai/frame_25.txt deleted file mode 100644 index c2c5b30b29..0000000000 --- a/codex-rs/tui2/frames/openai/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - apnonppppa - p opoaannioiip - eieeppoeipnnniiia - ipoi e ni onipone - paanpeoaneaio ieeneo - npiiiaopeaie eipieeip - ieioi iaaenaeineaoini - iiieieo ianneinainiin - apnioiaannnnpiinnipoin - epoieipiaiinoainoneni - nioeniaoooooopeinieia - iaoinpnnppoeaepeeie - aaaianaonepaeaiie - nennnaonioo oio - ipoonppapio - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_26.txt b/codex-rs/tui2/frames/openai/frame_26.txt deleted file mode 100644 index 09a947d35d..0000000000 --- a/codex-rs/tui2/frames/openai/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - anoooopp - eoinenipnip - eoooinninoonp - noopiiiaiiapea - iapnieiooneninn - n oieeeooanonne - i ooniieiinnop - ep niiiaaniieii - i enonpipnnenii - eaiepiaaponnoi - iaoeioonpeapnii - naiien eeoiia - o oin aaeiep - ioonnooaoon - neeaoiano - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_27.txt b/codex-rs/tui2/frames/openai/frame_27.txt deleted file mode 100644 index b3fef11ac8..0000000000 --- a/codex-rs/tui2/frames/openai/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - nooopp - ineeien - noa oieoi - iiiaiinii - i aiapon - iaeaoaiiia - n oiiaoii - i aiiinoii - ie e ipiiii - on eoaiaoii - ip aaoopeo - iippiiaei - innpnpeia - naainnee - ono ane - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_28.txt b/codex-rs/tui2/frames/openai/frame_28.txt deleted file mode 100644 index 11fdcec520..0000000000 --- a/codex-rs/tui2/frames/openai/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - eoon - ipaia - n nia - ipaoi - i ii - i poi - aii ai - ipooi - en ai - iieeii - i ai - inpni - inoii - oaaei - i epi - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_29.txt b/codex-rs/tui2/frames/openai/frame_29.txt deleted file mode 100644 index 2dc6c66753..0000000000 --- a/codex-rs/tui2/frames/openai/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - poooop - pioe on - apii ooi - iiiianpe - n ioi i - ipiii aa - inniii a - iinii - iiioi a - ainii a - oiieia n - ieiii ai - iiiiaiai - inioeoio - iio ep - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_3.txt b/codex-rs/tui2/frames/openai/frame_3.txt deleted file mode 100644 index 9026d59a43..0000000000 --- a/codex-rs/tui2/frames/openai/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennppnnnnpa - appeniipionninaaiopa - pneonioooa aoi oopeaip - epeninppa ano iin - oeoieaeiiona naniia - einne epinnnn i nni - no i ooniaoip eoeip - ppipi ppioeni iaini - pooi niopiiopinppaaappaiaoni - pnenp eeoenni ioiaap nepnnainia - oai i oiippo ooooooooopeeepe - ooi np a eapaee - naeeonp ppneopio - nappaooannnnpenonaapo - aopoaeiaaaaineao - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_30.txt b/codex-rs/tui2/frames/openai/frame_30.txt deleted file mode 100644 index 73b4906d0e..0000000000 --- a/codex-rs/tui2/frames/openai/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - anooopp - aeniinoonn - ooaipnnippn - iinieianin np - noiianipip i - ioiap oiiiiei - neiiina neni na - oi opnipioip a - oiaoninineip a - oiiniaaii pp a - niinnaoiiipii - ioeepeoniio i - niniieoiipie - pappnaeipeo - npeiaaano - aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_31.txt b/codex-rs/tui2/frames/openai/frame_31.txt deleted file mode 100644 index cc71fce920..0000000000 --- a/codex-rs/tui2/frames/openai/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeeopopnp - aponppaopnoop - pineepnaaeoiaon - piepaaonapnniniin - aiiiipponaponieaa - niaaiaioonnopeeipoi - eii iaoinenenoioiei - nnp iioin iaooiinei - inpaioaiiiepiiia i - oiiieeiiaaanainii o - aoaepip eoooaeioia - iiinnnp ano i - niona p e ioaea - einieaiapiopia - ooniapoeeno - aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_32.txt b/codex-rs/tui2/frames/openai/frame_32.txt deleted file mode 100644 index c0d6573da7..0000000000 --- a/codex-rs/tui2/frames/openai/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - pppppnnipa - noiooiniiooiip - eaonappaepaoionoop - oeeiapanp p panipp p - eeipionooin oapinenonp - nennpnoiaeiipa ipe o - oionpaoniippipnaeaeeioa - iiipa oaiioeoinpaoniooa - niipa oaioaaipppiiiniona - oooaaiononaaaaaiioinipa - pnoiie iooooooaneeeee - npiann pnieoi - oooiiipa aeaie p - npnoipinnoaapoae - innaappeoaoo - aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_33.txt b/codex-rs/tui2/frames/openai/frame_33.txt deleted file mode 100644 index 56ef96d36a..0000000000 --- a/codex-rs/tui2/frames/openai/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appnpnpniea - apoiinonieipioioe - aenipoppnoa ooeiipop - pneieaipa oiinao - aeieaiienan ipnna - eep nannanp enean - iip npoiain ioii - oipp poonenp noio - iii i pa eopppnnnpiipeaini - aeiooeepeaiiaaaaaiioaaaiie - nnnp ieie e ooooooo iipeaa - npnopop eenene - onooponp apipeono - nioonpipnpaonpnoio - onnppaaaaponpo - aaaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_34.txt b/codex-rs/tui2/frames/openai/frame_34.txt deleted file mode 100644 index b6e87c62f1..0000000000 --- a/codex-rs/tui2/frames/openai/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - apnppppnipa - apopennioiponoonpna - aeepiooeioa aannpooop - peaioaepa oiponn - eeinpei io p nnona - peniae nnoinon npnin - ioaia onaoiaa inia - ninoe ioaieoi e an - poeeo eneeoannppppppiaao iaa - iinpeppaoeeoiiiaieaaapeaiiain - eanp inioa oooaaaaa pepnio - epnnnna eneieo - npaipnnp apnoaepa - pneioippnppnonoapoeo - oinnnaaaaannona - aaa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_35.txt b/codex-rs/tui2/frames/openai/frame_35.txt deleted file mode 100644 index 899d6766b7..0000000000 --- a/codex-rs/tui2/frames/openai/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - appnnpppnppa - ponoeioopoioieoiona - aeeinpoiaooa aoonoeoiop - peepepnpa opania - eeiieen onop inoip - nenepa oiaoeeoa inni - n ee nn ooae iann - iiie ennneo e oe - e pa piienoeinnananpii iino - ipano eenoopein npaaaaeoieo io - eanpoenaieaa aoaaaaoaaoeeapi - oeanei aeo pa - nnaanpep pneaooo - oaaoioipeiipineooaeno - oainnaaeaappooa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_36.txt b/codex-rs/tui2/frames/openai/frame_36.txt deleted file mode 100644 index 9a23d2ddd6..0000000000 --- a/codex-rs/tui2/frames/openai/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapnppppnppa - anoonpenepnpnppooopa - pnonppopooaa aooiopipoip - aioopaaaa ooinoi - peannoinanpe aneo - e pea oa oiep onao - i n a n onn iaip - iini an aee ni i - iooi ee pooopppapppppa inii - aoin piaaeie aiaaaaaaaii neoa - o oin papea oooaaaaao ipoe - oponne peioe - neiipip pppoepa - opaooonaniapinopopnpo - aopeiappappppooo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_4.txt b/codex-rs/tui2/frames/openai/frame_4.txt deleted file mode 100644 index 0c76cc5ce8..0000000000 --- a/codex-rs/tui2/frames/openai/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennpnnpnppa - apopoie inneonioeopa - poniiipooa aainaoienna - eeipioepp oioonon - eieaininnaip nonion - eiiai oaiieon o nnip - onina ooonpoip p oni - ien pieoaii iii - ai ia nieoaeepipppaaaapp iio - ine i peeepaiannipeapeeanieeoi - ainonpnnpne oooooooooapiiia - pinoiaa paneooo - nnpea pa aeaponie - iiinaoonnnnnppnopnioa - onnieiaaaaanpoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_5.txt b/codex-rs/tui2/frames/openai/frame_5.txt deleted file mode 100644 index 2b06cade09..0000000000 --- a/codex-rs/tui2/frames/openai/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennnpnnnppa - apopeoeiipinpnaiopp - oaenpiooa oanion pop - pieieienp paiiiia - pneoeaapianp a ninia - iiiie nainneia peii - iei p anainoip ooipip - ieiao panieai piai - ipiao eaiopionaipiaaappaoiei - aninppepoo io eennapnepeieoia - npiipipnnepa oooooooopinine - nnpenn a eaeopa - aeoninpa anppoono - aioipopnninappnoaino - apoaooaaapappoa - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_6.txt b/codex-rs/tui2/frames/openai/frame_6.txt deleted file mode 100644 index 2ca8bb0bc7..0000000000 --- a/codex-rs/tui2/frames/openai/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aennnpnnppaa - paneoinopippnapopa - pppiiioo aoienopoena - epioioanp npiiiip - oniiionnpon enninp - npeieaapninaip ennei - a pii aniean apiai - i iii ainieii ipnii - aeipi eoiionepaipiaappaenei - iinpeaaanp ienopiipnnnnipin - e i eiiineo ooooooooannei - p n epa peaeeia - eanaoop apaaeopp - aoiopop niaappoo eno - apnoppaaaaappo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_7.txt b/codex-rs/tui2/frames/openai/frame_7.txt deleted file mode 100644 index f66ddaf5a6..0000000000 --- a/codex-rs/tui2/frames/openai/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aeinopnnppa - poapnipopnnnn aop - o piiioa aaia oeaop - nniiiinp ainoiep - aoioiiaienp e oiin - i niio aonnin ppiii - anoi aainini ooaii - aoeii npeioia o iii - eoio poaeineiinnaiinnonii - ipoiineiieoieniinnpnpnnini - n naeoioae ooooooooaeii - oa eaeaa popeneo - onppnnna paipoeoo - naonionniaaapoiope - oiooieiaapanoo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_8.txt b/codex-rs/tui2/frames/openai/frame_8.txt deleted file mode 100644 index e54163d2c8..0000000000 --- a/codex-rs/tui2/frames/openai/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - aapanppnppa - pooeiiionn i op - ainpieeo poieionpap - aonoeippi o naiin - o eiepnien eoniip - iopinaaaniao npiii - ioiii oanniai iaieip - npiie ipneipoe eaeii - n niiaa eoniiepppppniii - noenaiopipiininaaiino - eenniniiieoaoaoonooeni - opoiniaa epeeeo - onaonnna aenaipeo - eeoipo nnponenpia - iiaooniappena - aa \ No newline at end of file diff --git a/codex-rs/tui2/frames/openai/frame_9.txt b/codex-rs/tui2/frames/openai/frame_9.txt deleted file mode 100644 index a339de1118..0000000000 --- a/codex-rs/tui2/frames/openai/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - enaoeppnp - eoaeiioon apna - e piieoaane naip - o iienna aapinaipp - eoonnooiop oeopepii - i iiioainnaa ioioiii - a ioniooinnpoepe aii - i ieapniiiaennai ii - opio eioiiiipiipiia - iaiiiapiaiaiiiiaaiii - o aaennieoooooiioeni - niiinpa pe aeia - npninn enanieo - aponaioepnnnoia - opa onninpeo - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_1.txt b/codex-rs/tui2/frames/shapes/frame_1.txt deleted file mode 100644 index 244e2470b4..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△◆△●□□●●□▲◆ - ◆●▲△□○□△□○●◇◇●◆ ◆■□◆ - ▲◇□◇□□□■○◆ ◆■□◆■◇●◇◇◇□ - ●□◆○□■▲▲◆ △□◇●◇▲ - ○○●△■○◇○◆○○ ■△◇○○▲ - ◇□ □◆ ◇□△●◇◇▲ ■△○◇◇▲ - □○■▲□ ■○◇□△■◇◆ ◇△◇ - ◇◇◆◇◆ ▲△△◇●◇□ ■◆◇ - ◇●◇■◆ ●◇◇○○◇■△◇□□□□□□◆□▲ ●■ ◇ - ◆◇●□ ◆●□◆ △□ ◇■◇◆◆◆△△▲▲▲◇△▲△▲◇ - ○○◆■○ ○○▲△△◆ ◆○□■■□ ○□■△▲●◆△ - □○▲ ■▲ ◆ ▲■△□◆◇ - ○○▲◆○□◆ ◆●◆□◇◆□■ - ○□▲○◆ □□△●●●●△●□□◆▲◇□ - ◆□■□◇◇◇◆◆◆▲◆●□□■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_10.txt b/codex-rs/tui2/frames/shapes/frame_10.txt deleted file mode 100644 index f306dffc08..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆□□□□○□□◆ - □■◆□□□○◇△◆□▲ - ○◆▲◇◇△◇◇▲◇□○▲▲ - ◇◆◆△○◇●◆△○▲■■○○▲ - △ ●◇◇■◇○ ■▲△◆△◇△ - ◇◆ ■◇□◇△△○ ◆◆■◇ - ○ ◇□■□◇◇◇◇□ ◇△▲ - ■ ◇◇○□△□◇◇▲◆◆△○◇◇ - ■ ◇○ ○○◇●◇◇□○□●◇◇ - ◇ ▲◇○▲◇◆△◆□◆◆◆◇□◆ - ▲ ■◇◇◇◇◇■■ ○▲■○◇◆ - ○◆■▲○▲□■ ◆◇■■▲△△ - ◇■ ◇◇◇◇□▲△▲△◇△◆ - ●◆□□△◇□●◆ △△■ - □▲ ◆□○◆▲●□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_11.txt b/codex-rs/tui2/frames/shapes/frame_11.txt deleted file mode 100644 index dcf944902b..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▲□□□□□□◆ - △■ ●◇◇◇○○▲ - △■◆◆◇◇●○□△○▲ - ◇◆◆◇◇●●▲◆●△△ - ◆■△●○◇□○■●◆◇◇▲ - ◇□◆◇◇□◆●●◇◆△◇◇ - □□ ▲◇◇◇◇◇◇◆◇●○ - ◇ ◇◇△◇◇○△ ◇■◇ - △ ■◇△◇◇□△□◇◆◇ - □□ ◆◇△●▲■△◇◆◇○ - ■◆▲ ◇◇◇◇●△◇○○◇ - ○◆▲△◇◆□△□□●◇◆ - ◆ □◇○○○◆◇●■ - ○□ □◇◇▲◆△□◆ - ○◆ ■□□◆□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_12.txt b/codex-rs/tui2/frames/shapes/frame_12.txt deleted file mode 100644 index d8d1fbf334..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□□△ - ▲●◆◆△◇▲○ - ■ ◇△○□▲ - △□◇◇◇●●■◇ - ◇ ■◇◇◇◇△△◇ - ◇■△△□○■◆◇■ - ◇ ◇△○◇◇○ - ◇ △□○◇◇■ - □▲ ◇△◇◇□◇◇ - ◇ △◇□●□◆ - ◇△ ◇■●□□△ - ▲ □◇ ◆▲◇ - △ □□◇▲□○◇ - ■○△■▲◇○◇◆ - ○ ■◇○△△ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_13.txt b/codex-rs/tui2/frames/shapes/frame_13.txt deleted file mode 100644 index 1387fc9b91..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - △□□□▲ - ◇◆◆◇◇ - ◇◆◆◇■ - ◇□□◇◆ - □ ◇◇ - ■△▲◇◇ - ▲ ◇◇ - □ ■◇ - △□ ◆◇△ - ◇■ □●◇ - ■◆△△ ◇◆ - ◇◆◆◆◇ - ◇□▲◇◆ - □◆◆◇● - ▲ △■● - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_14.txt b/codex-rs/tui2/frames/shapes/frame_14.txt deleted file mode 100644 index 70a5070ba9..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□● - ▲△◆◆△○ - ◇◆◇◇△ ◇ - ◇◇△◇■○◇▲ - ◇◇◇○ ◇ - ◇□◇◇ ■◇ - ◇◇◇◇ △ □ - ◇◇○◇◆ ○ - ◇◇◇◇ ■ - ■○△△ ●△ - ○◇■■▲△▲◇ - ◇□◇◇◆◆◆◇ - △ ■◇●●●◆ - ◇○△□◆◆△ - ◆□ ■●△ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_15.txt b/codex-rs/tui2/frames/shapes/frame_15.txt deleted file mode 100644 index 584e0e043a..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□□◇◆ - ◆▲●◇◇○△□▲ - △△◇□●○◇■□◇ - ◇△◆ △△▲△◆◆◇ - ◇◇●■●○◇◇△■○◇ - ○◇◇▲◇◇△△◇ ■ - ◇◇ ◇◇◇◇◇◇▲◇ - ◇◇△◇◇□◇◇◇ ■▲◇ - ◇◇□▲▲□◇◇◇◆△△◇ - ◇◆◇●△◇○◇□△ △◇ - ◇△◇◇◇◇◆◇ ◆ ● - ◇□□△○◇◇○▲ ● - ■◇○▲ △△△◆ ●◆ - ◇○◇◇○○△□○△ - ○○□○◆◆◆△ - ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_16.txt b/codex-rs/tui2/frames/shapes/frame_16.txt deleted file mode 100644 index af6c836855..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●□■□□□◇◆ - △○◇◇□◇◇○◇●○ - ◇△△■○△●○◇◇ ■○ - △△△ ●□◆○◇○◇◇□□◇ - ◇◇●◆△□■●◇◇□△◇◆○ - ◇◇◇□◇■ ◇○● ◇ ◇△◇ - ○○◇ △◆▲◇◇◇◇△□■▲ ◇ - ◇△● ◇◆◇◇△◇◇◇■● ◆◇ - ○○◇◇◇□◇△○◇◇■□□ △ - □■◆◆▲●●○□◇△◆◇ ▲◆◇ - ◇◇◇□■■△□○◇●■●△◇◇◆ - ◇◇□ ◇◆ ◆△△▲ △ - ○◇□ ◇ △▲△◆▲◇ - ○◇◇■◆□◇△△□◆◇ - ■△△◆●△◆◆●□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_17.txt b/codex-rs/tui2/frames/shapes/frame_17.txt deleted file mode 100644 index 4a158cf609..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▲●□□□●●◇▲◆ - ◆●□□◇◇◇□●□◇▲■○▲ - ▲□○△◆●●◆□▲■◇◇◇◆■○ - △◇△◆△◇■◆◆ ■◇●○◇◇◇●○ - ◇△◆ ◇ ◇■△○●○△△ ▲ - ◆●□▲△◆ ▲△△◇ △▲●△◇△△ - □ ●△◇ ▲△△◇◆ □■◇◇△●◇ - ▲■●◇ △△◇◇△◇▲◇◇●●□ - ▲△□△○●●□◇○◆◇○○△○◇◇◇ ◇ - ◇■◆●◆◆◇△□○△◇◇○ ○□■□○ - ○○○△■■■□□□□○◆◇□△△○ ○■ - ○◇◇◆□ ◆△◇◇◆ ● - ◆○○ □○◆ ▲■▲△○◆△ - ▲ ◇□□●○□◆◆●□△◇■ - ◆□●△◆▲□◇□◆□□ - ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_18.txt b/codex-rs/tui2/frames/shapes/frame_18.txt deleted file mode 100644 index 16bf8c1b58..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●●□●●□●◇▲◆ - ◆□■◆●▲□□◇◆◆▲■□●●◆ - ◆△△◇□■□●■▲■△◆■□△△○ □ - ▲◇◇□△▲○■□ ■○◆◆△●◇○●○○○▲ - ◆○△△ △◇ ◆■□◆○△◇△◇◆○●○○◆ - △○◇ ▲▲◇△◆◆◆△▲□▲◇■▲△○△◆△◇ - ◇◇○△△ □△□△◇■◆△■□◇●◇●▲ - ■○▲◇◇ ○○■◇◇●○ ◇●◇■ - ○■■ ▲○●●●●●□◇◇▲◇□△○▲ ◇□◇◆ - ◆○○◇□○□ ◇◇△◆◆○◆◇◇◇◆●◇◆○ ▲ - ◇△◇○◆■■□□□□□◆ ○◆◆△◇△□△◆▲◆ - ○○ ▲○▲ ◆◆□■△□◆■ - ○○□□○○△ ◆□○◆△○▲◆ - ■◇△□□□□●●●○■□◆△◆▲□ - ■◇◇◆△◆◆▲△□■●□■ - ◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_19.txt b/codex-rs/tui2/frames/shapes/frame_19.txt deleted file mode 100644 index e1bc51ae1b..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆●●□◇□●●◇△◆ - ◆□◇◇◇○△●●●◆□◆▲◇□▲▲ - ▲□●●△■◇□□◆ ◆□△▲◆□●○ - △●◇□▲○◆ ◆●●□▲○◇□▲ - ◇◇△◆△◆ ▲◇○■△■◇○ ◇○▲ - ◇○△□■ ◆◇□◇△ △△ ■△□◇ - ○●◇◇■ △□○△◇□▲◆ △△△□◇ - ◇◇◇◇ ◇●■○◇■▲ ◇ ◇◇◇ - ◇□◇○▲●●●●●●◇◇◇◇□□●□◇● ○ ■◇◇ - ○●△▲◇●□△○□□○▲◆▲ ○◆ △○◆○▲△△◇◆ - ■○■△□◇■■■■■□□■ ○○◆△◇△△ △△■ - ○○○□◆○ ◆△△◆△△■ - ■ ◆○◇◇□▲ ▲●●△△◇●◆ - ■■△◇□△◇□◆◆●●△□■ ●□◇■ - ■◇△◆△▲■◆▲△○□▲○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_2.txt b/codex-rs/tui2/frames/shapes/frame_2.txt deleted file mode 100644 index af71459f5e..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△◆△●□●●●□▲◆ - ◆□▲△□●□◇◇◇●◇◇●◆■◇□□◆ - ▲◇□◇○□□◇□◆◆ ◆■□●□△◇◇●◇▲ - ◆◇△◆△□■▲▲◆ ▲▲○◇△◇▲ - ▲○■△□○◇○◆○○◆ ○▲◇○◇▲ - ■△□■◆ ◇□○□△○□ ○○◇□◇ - ▲◇■□◇ ○△◇●◇◆◇▲ △■◇■◇ - ◇■ ○◆ ▲◇□◇◇●□ ◇■△△◇ - □■■●◇ ●◇○○◆◇■△◇□□□□□□□□▲◇◆◇◇□ - ◆●■◇ △◇◇■ △□ ◇●○◆◆◆◆△△◆◆◇□△ ◇ - ○○○ □ □○△△◇■ ◆■□□□□□□□■◇▲□◇◇ - ■○◆△△▲ ◆ ▲□●△▲□ - △○◆◆◇□▲ ▲●◆□ ◆◇■ - ◆◇○●◆◆■□□△●●●●△●□◆◆◇◇□ - ◆□□□□◇◇◆◆◆◆◇●△□■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_20.txt b/codex-rs/tui2/frames/shapes/frame_20.txt deleted file mode 100644 index c5eb01382d..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆□●◇◇●□●▲◆◆ - ▲●●■●▲◆▲●●●◇◇◆○□○□▲ - ▲▲■◆□□■◇□■ ◆■○■○□□◇□◆ - ◆◇■◆□◇□ ◆▲□◇○△□○○ - ◆◇ ▲◇□ ◆△ ◆◇◇○□○◇◇○ - △◆▲◇■ □□■●◇■△ ■○◇◇○ - ●◇●□◇ ▲□ △□□○ ▲○◇△ - ◇◆○△◆ ■■●△◇■▲ ◇◇■◇ - ◇△◇◇●▲●●●●●●●●◆▲■●○○○○□ △ ◇○ - ■◇○◇◆■ △◆◆◆◆◆◆◇○ □○○■◇●○ ▲△ ◇◇ - ○▲○○■■■■■■■■□■ ○▲●◇◇▲◇ △○ - □ ○□△▲ ◆△□△◇△ - ○◆○□▲◇◆ ▲●□◆●□■ - ○▲▲△■◇□○●●▲△■◇■◆▲△□■ - ◆□□◇◆ ●◆◆◆◆◇□■■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_21.txt b/codex-rs/tui2/frames/shapes/frame_21.txt deleted file mode 100644 index 944b99f058..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆▲●□●●□●□▲◆◆ - ▲□□◆◆◆◆□◇●◇□●◆◆▲□□◆ - ●□○◆□□○■◆ ◆○◇□□◆◆□▲ - ▲□◇●■■ ◆▲●□▲●□▲ - △▲△□◆ ▲◇△◆○ ○○◆○▲ - △■△◇ ▲□□◆△△ ○○○▲ - ◇■◇◆ ◆ △◆▲△■ ○●○■ - ◆○ ○● ■◇▲ ◇ ◇ - ◇ ◇ ▲□●●●●●□□□ ○◇▲■◇▲ ●△ ◇ - ◇ ◇○ ◇◆ ◆▲◇ ○■◆□◇▲ △■△△ - ● ■△ ◆◆■■■ ■◆ ■□◆▲● △◆ ▲ - ■□ △●◆ ◆□■ △ - □◆ ○▲◆ ◆△●□■ ▲◆ - □●◆ □■□□△◇◇●△●□■■ ▲◆ - □□□●□◆◆◆◆▲◆●□□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_22.txt b/codex-rs/tui2/frames/shapes/frame_22.txt deleted file mode 100644 index 60ea930d46..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□●□●●□●□▲◆ - ◆□△□◇△◆△◇△○◇◇●◇□△□□◆ - ▲●□●◇○□□◆ ◆■□○□□□◆◆□▲ - ●□◇□□■ ▲▲◇◇◇◆□○ - ○◇◇△■ ▲◆△◇●◇△△●■◇ - ○◇□△◆ ▲◇△◇●○△◆ ○◇○●◇ - ◇□□○△ ◇□◇○□△■ ◇◇△◇ - ◇◇●◇◆ ◇○◇◇○○ ◇○◇■◇ - ◇◇▲■▲ □◇●●●●●●□□◆○□◇◆ ○▲ ◆◇●■ - ○◇● □◆○○▲ △▲■◇△△◇ ○□●◆■◇▲▲ △○● - ○○○◆◇ ■■■■■□□■◆ ◆△□□◆△△△■▲◆ - ○□○◆○▲ ◆□▲■◇▲■ - ■■□● □□◆ ◆△□▲■□◆△ - ■△□□▲■□◇●●□●●△●□■■■▲□ - ■◇■◇◆●◆◆◆▲●◆△●□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_23.txt b/codex-rs/tui2/frames/shapes/frame_23.txt deleted file mode 100644 index 5d340640bf..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□◆●□□□□●□◆◆ - ◆●◇□◇○◆▲◆●●▲◇◆◇□◇□◆ - ▲■□◇■▲●□◆ ■◇△□◇○●◇◇▲ - △●◇□●△◆ ◆●●○◇◇●○ - △△◇◆△■ ▲△▲▲■□△□○◇○ - ▲○◇■△◆ ◆□□□■■△□■◇◇○◇▲ - ◆◇◇■◇ △△△△ ◆◇◆ ■ ◇■□ - ◇△◇ ◇ ◇■◇▲◆○▲ ◇ □△◇ - ○■○ ◇□◇●●●●●●●●◇◆□◇◆■○◆ ◇ ◇◇◇ - ◇○○◆◇■●■ △◆○ ○◇◆○▲◇ ○◆◇ - △■◇ ◇ ◆■■■■■■■ ■◆△●●◇◆△○△◆ - ▲◇◇◆◇△ ▲△◇○○△◆ - ○ ◇▲■△◆◆ ◆△■●□△□△ - ○●○□●□□○◇●●◆□□■▲◆□□◆ - ■◇ □●○◇◆◆□◆◆□□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_24.txt b/codex-rs/tui2/frames/shapes/frame_24.txt deleted file mode 100644 index 558224147d..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◇◆●□□□●●▲◆ - ▲■◆△■◇◆◆◇●●□▲◆■□ - ▲■■●○△□■◇ △◆△□◇□□▲ ○ - ▲■△△▲△◇●▲○◆◇△◆ ◆□○◇○◆■ - ▲ △△○△▲ ◆■■△○◆◇◆◆○◇▲■ - ◇▲○□○◆ □▲▲△ △◇● △■○ - ◇ ◇◇ ▲△◆▲△■△○○◇○○◇○ - ◇ ○◇◇ ◇▲●△○◆◇◇◆△△◆◇◇ - ◇△ ◇●●●△□□◇◇◇□□◆□▲◆◇ ◇ ◇◇ - ◆ △ □◇△▲▲●●▲▲◇△○○□□◇ ◇□◇ - ○ ■○◇○□■■■■■■◆ ○▲△□○◇●△▲◆ - ○ ○▲□○ ◇ ■△●△ - ○△□○◆◇△ ◆●■◆△△◆□ - △▲□▲○○■◇◇△●●●◇○◆△◆ - □△◆□□●○◆◆●●□□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_25.txt b/codex-rs/tui2/frames/shapes/frame_25.txt deleted file mode 100644 index 38d3250764..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆□●□●□□□□◆ - ▲ □▲□◆◆●○◇□◇◇▲ - △◇△△▲▲■△◇□○●○◇◇◇◆ - ◇□□◇ △ ●◇ ■○◇▲□○△ - ▲◆◆○▲△■○●△◆◇■ ◇△△○△□ - ○▲◇◇◇◆□□△○◇△ △◇▲◇△△◇▲ - ◇△◇□◇ ◇◆◆△○◆△◇○△◆■◇●◇ - ◇◇◇△◇△■ ◇◆●○△◇○◆◇●◇◇○ - ○□○◇■◇◆◆●●●●□◇◇○○◇□□◇○ - △▲■◇△◇▲◇◆◇◇●□◆◇○□●△○◇ - ○◇□△○◇◆■■■■■■□△◇○◇△◇◆ - ◇◆■◇●□●○▲▲□△◆△▲△△◇△ - ○◆○◇◆○◆□●△▲○△◆◇◇△ - ○△●○○◆□●◇□□ □◇□ - ◇▲■□○□▲◆□◇□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_26.txt b/codex-rs/tui2/frames/shapes/frame_26.txt deleted file mode 100644 index 4aac44389a..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●□□□■□▲ - △□◇●△○◇□●◇▲ - △□■■◇●○◇○■■○▲ - ●□■▲◇◇◇◆◇◇◆□△○ - ◇◆▲○◇△◇■□○△●◇○● - ○ ■◇△△△■□◆○■○○△ - ◇ □■●◇◇△◇◇●●■□ - △▲ ○◇◇◇○○●◇◇△◇◇ - ◇ △○□●□◇□●●△○◇◇ - △◆◇△▲◇◆◆▲□○○□◇ - ◇◆■△◇□■○▲△◆□○◇◇ - ○◆◇◇△○ △△■◇◇◆ - □ □◇○ ○◆△◇△▲ - ◇■□○○■□◆□□● - ○△△○□◇◆○□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_27.txt b/codex-rs/tui2/frames/shapes/frame_27.txt deleted file mode 100644 index 9896590f79..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ●□□□□▲ - ◇●△△◇△○ - ●■◆ □◇△■◇ - ◇◇◇○◇◇●◇◇ - ◇ ◆◇◆▲■● - ◇◆△◆■◆◇◇◇◆ - ● ■◇◇◆□◇◇ - ◇ ◆◇◇◇●■◇◇ - ◇△ △ ◇□◇◇◇◇ - ■● △■◆◇◆□◇◇ - ◇▲ ◆○□□□△■ - ◇◇□□◇◇◆△◇ - ◇●●□●▲△◇○ - ○◆◆◇○●△△ - ■○■ ○○△ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_28.txt b/codex-rs/tui2/frames/shapes/frame_28.txt deleted file mode 100644 index 16b349dc3d..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - △□□● - ◇□◆◇◆ - ● ●◇◆ - ◇▲◆□◇ - ◇ ◇◇ - ◇ ▲■◇ - ○◇◇ ◆◇ - ◇▲■■◇ - △○ ◆◇ - ◇◇△△◇◇ - ◇ ◆◇ - ◇●□●◇ - ◇●□◇◇ - □◆◆△◇ - ◇ △□◇ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_29.txt b/codex-rs/tui2/frames/shapes/frame_29.txt deleted file mode 100644 index 24be1563b2..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - □□□□□▲ - ▲◇□△ □○ - ○▲◇◇ ■■◇ - ◇◇◇◇○○▲△ - ○ ◇■◇ ◇ - ◇▲◇◇◇ ◆◆ - ◇○○◇◇◇ ○ - ◇◇●◇◇ - ◇◇◇□◇ ◆ - ◆◇●◇◇ ◆ - □◇◇△◇○ ● - ◇△◇◇◇ ◆◇ - ◇◇◇◇◆◇◆◇ - ◇●◇□△■◇■ - ◇◇□ △▲ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_3.txt b/codex-rs/tui2/frames/shapes/frame_3.txt deleted file mode 100644 index 3f55b79ac5..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●□□●●●●▲◆ - ◆□▲△○◇◇□◇■●●◇●◆○◇□□◆ - ▲●△□●◇□■■◆ ◆■◇ □□□△○◇▲ - △□△○◇●▲□◆ ◆●□ ◇◇○ - ■△□◇△○△◇◇□○◆ ○○○◇◇◆ - △◇○○△ △▲◇○●○○ ◇ ○○◇ - ●□ ◇ ■□○◇◆■◇▲ △□△◇▲ - ▲▲◇▲◇ ▲▲◇■△○◇ ◇◆◇●◇ - ▲■■◇ ●◇□□◇◇□▲◇●□□◆◆◆□□◆◇○■●◇ - ▲○△○▲ △△□△●●◇ ◇□◇◆◆▲ ●△▲●○◆◇○◇◆ - ■○◇ ◇ ■◇◇□▲■ ■□□□■■■□□▲△△△▲△ - ■□◇ ○□ ◆ △○□○△△ - ●○△△□○▲ ▲□●△■▲◇■ - ●○□▲◆□□○●●●●▲△●□●◆◆□■ - ◆□□□◆△◇◆◆◆◆◇●△○■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_30.txt b/codex-rs/tui2/frames/shapes/frame_30.txt deleted file mode 100644 index 54886a319d..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆●■□□□▲ - ◆△●◇◇○□■●○ - ■■○◇▲○○◇□▲○ - ◇◇○◇△◇◆○◇○ ○▲ - ●□◇◇◆○◇▲◇▲ ◇ - ◇□◇○□ □◇◇◇◇△◇ - ○△◇◇◇○◆ ○△○◇ ●◆ - ■◇ ■□●◇▲◇■◇▲ ◆ - ■◇◆■○◇●◇○△◇▲ ◆ - ■◇◇○◇◆◆◇◇ □▲ ◆ - ○◇◇●○◆□◇◇◇□◇◇ - ◇□△△▲△□○◇◇■ ◇ - ○◇○◇◇△□◇◇▲◇△ - ▲○▲□●○△◇▲△■ - ○▲△◇○◆◆●■ - ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_31.txt b/codex-rs/tui2/frames/shapes/frame_31.txt deleted file mode 100644 index b3989b89df..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△△□□□□●▲ - ◆□□●□▲◆■□○■□▲ - ▲◇○△△▲●◆◆△■◇◆□○ - ▲◇△□◆◆□●◆▲○○◇○◇◇○ - ○◇◇◇◇□▲□○◆▲■○◇△◆◆ - ●◇○◆◇◆◇■■○●□▲△△◇▲■◇ - △◇◇ ◇○□◇○△●△●□◇□◇△◇ - ○○▲ ◇◇■◇○ ◇○■■◇◇○△◇ - ◇○▲◆◇■○◇◇◇△□◇◇◇◆ ◇ - ■◇◇◇△△◇◇◆◆◆○◆◇○◇◇ □ - ○□◆△□◇▲ △□□□◆△◇■◇◆ - ◇◇◇○○○▲ ◆●□ ◇ - ●◇□●◆ ▲ △ ◇□◆△◆ - △◇○◇△◆◇◆▲◇■▲◇◆ - ■■●◇◆□□△△●□ - ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_32.txt b/codex-rs/tui2/frames/shapes/frame_32.txt deleted file mode 100644 index 919eee3b0f..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▲□□□□●●◇▲◆ - ●□◇□□◇○◇◇□□◇◇▲ - △○□●○▲□○△▲◆□◇□○■□▲ - ■△△◇◆▲◆●▲ ▲ ▲◆○◇□▲ ▲ - △△◇▲◇□○■□◇● ■◆▲◇○△○□○▲ - ○△○○▲○□◇◆△◇◇□◆ ◇□△ □ - □◇■●▲◆■○◇◇▲▲◇▲●◆△◆△△◇■◆ - ◇◇◇▲◆ ■◆◇◇■△■◇●▲◆■○◇■■◆ - ○◇◇▲◆ ■◆◇■◆◆◇□□□◇◇◇●◇■●◆ - ■■■◆◆◇□○■○◆◆◆◆◆◇◇□◇●◇▲◆ - ▲○□◇◇△ ◇□■■□□□○●△△△△△ - ○▲◇○○○ ▲○◇△■◇ - ■□□◇◇◇□◆ ◆△◆◇△ ▲ - ○▲○■◇□◇●●□◆◆□■◆△ - ◇●●◆◆▲□△□◆■■ - ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_33.txt b/codex-rs/tui2/frames/shapes/frame_33.txt deleted file mode 100644 index c5598aa7a7..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□●□●□●◇△◆ - ◆□□◇◇○□●◇△◇▲◇□◇□△ - ◆△○◇□□□▲●■◆ ■□△◇◇□■▲ - ▲○△◇△◆◇▲◆ □◇◇○◆□ - ◆△◇△◆◇◇△○○○ ◇□○○○ - △△▲ ○◆○○◆○▲ △○△◆○ - ◇◇□ ○▲□◇◆◇○ ◇□◇◇ - □◇▲▲ ▲■■○△●▲ ○■◇□ - ◇◇◇ ◇ ▲◆ △□▲▲▲●●●□◇◇□△◆◇○◇ - ○△◇■■△△▲△◆◇◇◆◆◆◆◆◇◇□○○○◇◇△ - ○●○▲ ◇△◇△ △ ■■■■□□□ ◇◇▲△○◆ - ○□○□▲■▲ △△●△●△ - □○■□▲□●▲ ◆□◇▲△□○■ - ○◇■□○□◇▲●□◆■●▲○□◇□ - ■●●□▲◆◆◆○□□●▲■ - ◆◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_34.txt b/codex-rs/tui2/frames/shapes/frame_34.txt deleted file mode 100644 index 5a44de8256..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲●□□□□●◇▲◆ - ◆□□□△○●◇■◇□□●□□●▲●◆ - ◆△△▲◇□■△◇□◆ ◆○○○▲■□□▲ - ▲△◆◇□◆△▲◆ □◇▲□○○ - △△◇○▲△◇ ◇■ ▲ ○○□○○ - ▲△○◇◆△ ○○■◇○□○ ○▲○◇○ - ◇□○◇◆ ■○◆□◇◆○ ◇●◇◆ - ○◇●■△ ◇□◆◇△■◇ △ ◆○ - ▲■△△■ △○△△■◆●●□□□□□□◇◆◆■ ◇○◆ - ◇◇○▲△▲▲○■△△■◇◇◇◆◇△◆◆◆▲△◆◇◇◆◇○ - △◆○▲ ◇○◇□○ ■□□◆◆◆◆○ ▲△▲○◇■ - △▲○●●○◆ △●△◇△■ - ○□◆◇□○●▲ ◆□●■◆△▲◆ - ▲○△◇■◇□▲●▲□●□●□◆□□△■ - ■◇○●●◆◆◆◆◆●●□●○ - ◆◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_35.txt b/codex-rs/tui2/frames/shapes/frame_35.txt deleted file mode 100644 index 1c1728676b..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆▲□●●□□□●□▲◆ - ▲□●□△◇□□□■◇□◇△■◇□●◆ - ◆△△◇●□□◇○□■◆ ◆■□●□△■◇□▲ - ▲△△□△▲●▲◆ □□◆○◇◆ - △△◇◇△△○ ■○□▲ ◇○□◇▲ - ●△●△□◆ ■◇◆■△△□◆ ◇○○◇ - ○ △△ ○○ ■□◆△ ◇○○○ - ◇◇◇△ △○○●△■ △ ■△ - △ ▲◆ ▲◇◇△●■△◇●●◆●◆●□◇◇ ◇◇●□ - ◇▲○●■ △△●■□□△◇● ○▲◆◆◆◆△■◇△■ ◇■ - △◆○▲■△○◆◇△○◆ ◆■○○○○□○○□△△◆▲◇ - ■△○○△◇ ◆△□ ▲○ - ○●◆○●▲△▲ ▲●△◆□□■ - ■○◆■◇□◇▲△◇◇□◇●△□■◆△●■ - ■○◇●●◆◆△◆◆□□□■○ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_36.txt b/codex-rs/tui2/frames/shapes/frame_36.txt deleted file mode 100644 index 0cac995ed7..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆□●□□□□●□▲◆ - ◆●■■○□△●△□○□●□□■□□□◆ - ▲○■○▲□□▲□■◆◆ ◆■□◇□□◇▲□◇▲ - ◆◇■□□◆◆◆◆ ■□◇○■◇ - ▲△◆●○■◇○◆○▲△ ○○△□ - △ ▲△◆ □◆ ■◇△▲ ■○○□ - ◇ ● ◆ ○ ■○○ ◇○◇▲ - ◇◇○◇ ◆○ ◆△△ ○◇ ◇ - ◇■■◇ △△ ▲□■■▲□□◆□□□□□◆ ◇○◇◇ - ◆□◇○ ▲◇◆◆△◇△ ◆◇◆◆◆◆◆◆◆◇◇ ○△■◆ - ■ □◇○ □◆□△○ ■□□○○○○○■ ◇▲■△ - ■▲■○●△ ▲△◇■△ - ○△◇◇□◇▲ ▲□□■△▲◆ - □▲◆■□□●○●◇◆□◇●■▲■▲●□■ - ◆□□△◇◆▲▲◆▲▲□□□□■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_4.txt b/codex-rs/tui2/frames/shapes/frame_4.txt deleted file mode 100644 index 31e55f9cb8..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●□●●□●□▲◆ - ◆▲■□□◇△ ◇○●△□●◇■△□▲◆ - ▲□●◇◇◇□□■◆ ◆○◇○○□◇△○○◆ - △△◇▲◇■△□▲ ■◇□□○□○ - △◇△◆◇●◇○●○◇▲ ○■○◇□○ - △◇◇○◇ ■◆◇◇△□○ ■ ○●◇▲ - ■○◇●◆ ■□□○□■◇▲ ▲ ■●◇ - ◇△○ ▲◇△□○◇◇ ◇◇◇ - ◆◇ ◇◆ ●◇△■○△△□◇□□□◆◆◆◆▲▲ ◇◇□ - ◇○△ ◇ ▲△△△▲◆◇◆●○◇▲△◆▲△△○●◇△△■◇ - ◆◇●■○▲○○▲○△ ■□□□□□□□■○▲◇◇◇◆ - ▲◇○■◇◆◆ ▲○●△□□■ - ○○▲△○ ▲◆ ◆△○▲□●◇△ - ◇◇◇●◆□□○●●●●▲□●□□○◇□◆ - □○○◇△◇◆◆◆◆◆●▲□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_5.txt b/codex-rs/tui2/frames/shapes/frame_5.txt deleted file mode 100644 index a8ae0ab819..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●●□●●●□▲◆ - ◆□■□△□△◇◇□◇●□●◆◇■□▲ - □◆△○□◇□■◆ ■○●◇□○ ▲□▲ - ▲◇△◇△◇△●▲ ▲○◇◇◇◇◆ - ▲○△□△○◆□◇◆○▲ ◆ ○◇○◇◆ - ◇◇◇◇△ ○◆◇○●△◇◆ □△◇◇ - ◇△◇ □ ◆○◆◇○■◇▲ ■■◇□◇▲ - ◇△◇◆■ ▲◆●◇△◆◇ □◇◆◇ - ◇▲◇○□ △◆◇■▲◇□●◆◇□◇◆◆◆□▲◆■◇△◇ - ◆○◇○▲▲△□□■ ◇■ △△○●○▲●△▲△◇△□◇◆ - ○▲◇◇▲◇▲○○△□◆ ■□□■■■■■▲◇●◇○△ - ○○□△○○ ◆ △◆△□▲○ - ○△□○◇●□◆ ◆●□▲□■●□ - ○◇□◇▲□□○●◇●◆▲□●□○◇●□ - ○▲□◆□■◆◆◆▲◆▲□□◆ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_6.txt b/codex-rs/tui2/frames/shapes/frame_6.txt deleted file mode 100644 index e0b1f85454..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△●●●□●●□□◆◆ - ▲○●△□◇●□□◇□□●◆▲□□◆ - ▲▲▲◇◇◇□■ ◆■◇△●□□□△○◆ - △▲◇□◇□◆●▲ ○□◇◇◇◇▲ - ■●◇◇◇□○○□□○ △○○◇○▲ - ●▲△◇△◆○□●◇○◆◇▲ △●○△◇ - ◆ □◇◇ ○○◇△○○ ○□◇◆◇ - ◇ ◇◇◇ ◆◇●◇△◇◇ ◇□○◇◇ - ◆△◇▲◇ △■◇◇□●△□◆◇□◇◆◆□□◆△○△◇ - ◇◇○▲△◆○◆●□ ◇△●□▲◇◇▲●●●○◇▲◇○ - △ ◇ △◇◇◇●△■ ■□■■■■□■◆○○△◇ - ▲ ● △▲◆ ▲△◆△△◇◆ - △◆○◆□□▲ ◆□○◆△□▲□ - ◆■◇□▲■□ ●◇◆◆▲□□□ △●■ - ○▲●□□▲◆◆◆◆◆▲□□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_7.txt b/codex-rs/tui2/frames/shapes/frame_7.txt deleted file mode 100644 index 7e69d68d57..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆△◇●□□●●□▲◆ - ▲□◆□○◇□□□●●○● ◆□▲ - □ ▲◇◇◇□◆ ◆○◇◆ □△◆□▲ - ○○◇◇◇◇●□ ○◇○□◇△▲ - ◆■◇□◇◇○◇△○▲ △ □◇◇○ - ◇ ●◇◇■ ◆■○●◇○ ▲▲◇◇◇ - ◆○■◇ ○◆◇○◇○◇ □□○◇◇ - ◆■△◇◇ ●□△◇□◇◆ ■ ◇◇◇ - △■◇□ ▲■◆△◇○△◇◇●●◆◇◇●●□○◇◇ - ◇▲■◇◇●△◇◇△■◇△○◇◇○○▲●▲●○◇○◇ - ○ ●○△□◇□◆△ □□□□□□■■◆△◇◇ - ■◆ △◆△◆◆ ▲■▲△●△■ - ■●▲□○○○◆ ▲○◇▲□△□■ - ○◆■○◇□○●◇◆◆◆□□◇□□△ - ■◇□■◇△◇◆◆▲◆●□□ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_8.txt b/codex-rs/tui2/frames/shapes/frame_8.txt deleted file mode 100644 index b7bddd4156..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ◆◆□◆●□□●□▲◆ - ▲■□△◇◇◇□●● ◇ ■□ - ◆◇●▲◇△△□ ▲■◇△◇□○□○▲ - ◆□○□△◇▲□◇ ■ ●○◇◇○ - ■ △◇△▲○◇△○ △■○◇◇▲ - ◇■▲◇○○○◆○◇◆□ ○□◇◇◇ - ◇□◇◇◇ ■◆●○◇◆◇ ◇○◇△◇▲ - ○▲◇◇△ ◇□○△◇▲□△ △◆△◇◇ - ○ ○◇◇◆○ △□●◇◇△□▲□▲▲○◇◇◇ - ○□△●◆◇■▲◇▲◇◇●◇●◆◆◇◇●■ - △△○○◇○◇◇◇△■○□○□■○□■△○◇ - ■▲■◇○◇◆◆ △▲△△△■ - ■○◆□○○○◆ ◆△○◆◇□△□ - △△■◇▲□ ●●□□●△●▲◇◆ - ◇◇◆□□○◇◆▲□△●◆ - ◆◆ \ No newline at end of file diff --git a/codex-rs/tui2/frames/shapes/frame_9.txt b/codex-rs/tui2/frames/shapes/frame_9.txt deleted file mode 100644 index 4342d3c81e..0000000000 --- a/codex-rs/tui2/frames/shapes/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - △●○□△□□●▲ - △□◆△◇◇□■● ◆▲○◆ - △ ▲◇◇△□○○●△ ○◆◇▲ - ■ ◇◇△●●◆ ◆○▲◇○○◇□▲ - △□■●○□■◇□▲ □△■□△□◇◇ - ◇ ◇◇◇■○◇○●◆◆ ◇■◇□◇◇◇ - ◆ ◇□○◇□□◇○○▲■△▲△ ◆◇◇ - ◇ ◇△◆▲○◇◇◇◆△●○◆◇ ◇◇ - ■▲◇■ △◇■◇◇◇◇□◇◇▲◇◇◆ - ◇◆◇◇◇◆▲◇○◇◆◇◇◇◇◆◆◇◇◇ - ■ ◆○△○○◇△■□□□□◇◇■△○◇ - ○◇◇◇○▲◆ ▲△ ○△◇◆ - ○□○◇○○ △●◆●◇△□ - ○▲■○◆◇□△□●●●□◇◆ - ■▲◆ □○○◇●□△■ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_1.txt b/codex-rs/tui2/frames/slug/frame_1.txt deleted file mode 100644 index 514dc8ac49..0000000000 --- a/codex-rs/tui2/frames/slug/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-dcottoottd - dot5pot5tooeeod dgtd - tepetppgde egpegxoxeet - cpdoppttd 5pecet - odc5pdeoeoo g-eoot - xp te ep5ceet p-oeet - tdg-p poep5ged g e5e - eedee t55ecep gee - eoxpe ceedoeg-xttttttdtt og e - dxcp dcte 5p egeddd-cttte5t5te - oddgd dot-5e edpppp dpg5tcd5 - pdt gt e tp5pde - doteotd dodtedtg - dptodgptccocc-optdtep - epgpexxdddtdctpg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_10.txt b/codex-rs/tui2/frames/slug/frame_10.txt deleted file mode 100644 index bd3b8fafff..0000000000 --- a/codex-rs/tui2/frames/slug/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dtpppottd - ppetptox5dpt - ddtee5xx-xtott - edd5oecd-otppoot - 5 ceeged pt5d5e5 - ee pepx55o gedge - o xpgpeexep e5t - g eeot5tee-de-oee - g xo ooecxxtotcee - e teoted5dpdddepe - t geeeeegggotgoee - oeptotpg dxggt55 - ep eeexptct5e5e - cepp5etcdg55p - pt dpodtcp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_11.txt b/codex-rs/tui2/frames/slug/frame_11.txt deleted file mode 100644 index 9eaf147a6a..0000000000 --- a/codex-rs/tui2/frames/slug/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tppppttd - 5g ceeeoot - 5gddeecop5ot - eddeeoctdo55 - dg-coetopcdeet - eteeetdcced5ee - pp teeeeeedeoo - e ee5eeo5 ege - 5 pe5eep5tede - pp de5otg5eded - pe- eeeeo5eooe - od-5edp5ppcee - gd peooddecg - otgpeetd5pe - od pptdte - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_12.txt b/codex-rs/tui2/frames/slug/frame_12.txt deleted file mode 100644 index 11163a99b9..0000000000 --- a/codex-rs/tui2/frames/slug/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tpppt- - toed5eto - g e5ott - 5txeeooge - e pxeee-5e - ep--pdgdeg - e x5oeeo - e 5toeeg - pt x5eetex - e 5epcpd - e- egopp5 - t pegdte - 5 ppetpoe - pd5gteoee - o pxo-5 - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_13.txt b/codex-rs/tui2/frames/slug/frame_13.txt deleted file mode 100644 index eb072e40ad..0000000000 --- a/codex-rs/tui2/frames/slug/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - 5pppt - eddee - eedeg - epped - p ee - gc-ee - t ee - t ge - 5t dx- - eg toe - pe-- xe - eddde - etted - pddeo - t -go - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_14.txt b/codex-rs/tui2/frames/slug/frame_14.txt deleted file mode 100644 index 100f309302..0000000000 --- a/codex-rs/tui2/frames/slug/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tpppc - t5dd-o - edee- e - ee5egdxt - eeeo e - xpee pe - eeee - p - eeoee o - eeex g - gd55 c5 - oeggt-te - epxeddde - 5ggeoooe - eo5pdd5 - dp po5 - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_15.txt b/codex-rs/tui2/frames/slug/frame_15.txt deleted file mode 100644 index 5761f309d4..0000000000 --- a/codex-rs/tui2/frames/slug/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ttpppxd - etoeedcpt - 55epooegpe - e5e 55t-dde - eeogooee5gde - oee-ee55e g - ee eeeexxte - ee5xeteee p-e - eetttpeeed-ce - edec5eoxp- -e - e5eeeede e c - epp-dxeo- o - peot 555e ce - edeeoo-to5 - odpdddd5 - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_16.txt b/codex-rs/tui2/frames/slug/frame_16.txt deleted file mode 100644 index f9001140ed..0000000000 --- a/codex-rs/tui2/frames/slug/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dotgpptxd - 5deepeeoeoo - e55go5ooee po - 555 cpdoeoeeppe - eecd5ppoeep5eeo - eeepep eoo ge x-e - ooe 5eteeee5pgt e - e5c eeee5eeegc ee - ooexetx5deegpt 5 - pgddtooope-de tde - eeetgg5poecgc-xee - eep ee e55t 5 - oep e 5t5dte - oexgdpx55tde - pc-docddcp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_17.txt b/codex-rs/tui2/frames/slug/frame_17.txt deleted file mode 100644 index 696d932d40..0000000000 --- a/codex-rs/tui2/frames/slug/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - totttccxtd - dcppexxpopetgdt - tpo5dooettgeeedgo - 5e5d5egde pecoxeeoo - e5d x eg5ooo55 t - eopt5e tc5e 5to5e-5 - pgc5e t55ed pgee5oe - -goeg g55ee5eteeocp - t5p5oootxodeodcoeee e - egdcdde5po5eeogotpto - ooo5gggppppodep55o op - oeedp e5eee c - doogpod tpt5dd5 - t xptootedcpcep - etc5dttxpdtp - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_18.txt b/codex-rs/tui2/frames/slug/frame_18.txt deleted file mode 100644 index abb0da53d2..0000000000 --- a/codex-rs/tui2/frames/slug/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dootootcxtd - dtgdctttxddtgtccd - d5cepgpogtg-dgt55o p - teep-tdgp poed5oxocooot - do55 5e dgtdo5x5edocood - 5oe ttx-ddd5tptep-5d5e5xg - eeoc5 t5p5egd5gpeoeot - go-xe dogeecd eceg - ogg tooococtxetep5ot epee - dooepop xe5ddodxeedcxeo t - e5eoeggpppppe odd5e5p5e-e - oogtot eepg5pdp - odptdd- dpdd5ote - pe-ppttooodppd5dtp - gxed5ddt-tgctg - e \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_19.txt b/codex-rs/tui2/frames/slug/frame_19.txt deleted file mode 100644 index ffc4d2b475..0000000000 --- a/codex-rs/tui2/frames/slug/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddootxtoox-d - dteeeo5ooodpdteptt - tpcc5getpe epctepco - 5ceptde doottdept - ee5e5e tedg5geo eot - eo5pp depx5g-5 p-pe - doexp 5pd5ette 5c5te - eeee ecgoegt e eee - epeotoccoooxxxetpcpec o gee - dc5teop5dptotet dd codot5-ed - pog5tegggggppg dod5e55 55p - oodpdo e55d55p - pgdoxxpt tco-5ece - pg-ep5xtddoc5pg cpxp - gx-dc-pdt-dp-d - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_2.txt b/codex-rs/tui2/frames/slug/frame_2.txt deleted file mode 100644 index f4419e3d69..0000000000 --- a/codex-rs/tui2/frames/slug/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-dcotooottd - dtt5pcteexoxeodpeptd - tepeoppxpee egpop5eecet - de5d5ppttd -toe5et - tdg5pdeodood dteoet - p5tge epot5ot ooepe - teppe d5ecedet 5gege - eg oe tepeecp ep5-e - pggoe cedddeg-xtttttttttedexp - dope 5eep 5p eoodddd--ddet5geg - ooo p po--ep egpppppppgetpee - pod-5t e ttc5tp - -oddett todtgdeg - exdcddgptccocc-opedeep - eptptxxddddxc5pg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_20.txt b/codex-rs/tui2/frames/slug/frame_20.txt deleted file mode 100644 index 0039bd880b..0000000000 --- a/codex-rs/tui2/frames/slug/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtoxxototdd - tcogctdtoooeeddpott - ttgdtpgxpg egdgotpetd - degdpep dtteo5poo - de tep d5gdeedpoeeo - 5etep tppceg5 poxeo - cecte tp -tpd toe5 - edd5e ggccegt exge - e5eectocoooooodtpcddoop 5 eo - pededg 5eeddddeo poogeoo t5 ee - otdopgggggggpg otoeete 5o - p dp5t d5p-e5 - oddptxd tcpecpp - dt--gxtdcctcgxget5pp - eptxdgoddddepgg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_21.txt b/codex-rs/tui2/frames/slug/frame_21.txt deleted file mode 100644 index 87e3597d5d..0000000000 --- a/codex-rs/tui2/frames/slug/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtotootottdd - ttpeeddtxoxtcde-ptd - cpddtpdge edxptdept - tpecgp dtcptcpt - 5t5pe te5do ooddt - 5g5e tppd55 oodt - epee dg5et5p ocog - eo oc get e e - e e ttcccccttt detget c5 e - e xo ed dte dgepet 5g-5 - c g- eeggg pe ppdtc 5e t - pt ccd dpg 5 - pd d-d d-cpp te - pod pgptcxxccopgg -e - pttctddddtdctpe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_22.txt b/codex-rs/tui2/frames/slug/frame_22.txt deleted file mode 100644 index 8dfe7daaab..0000000000 --- a/codex-rs/tui2/frames/slug/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttotootottd - dt5pe-d5e5oeecet5ptd - tcpcxoppe egpopptddtt - cpxppg tteeedpo - oex5p td5eoe-5cpe - oep5e tx5ecd5e oeooe - etpo5 xteop5p xe5e - eeoee eoexdo edege - eetgt txoocccottdopedgot decgg - deo pdootg5tgx55e opcdgettg5oo - ooode gggggppge ecptd555gte - opodot dptgetp - pgtc ttd d-ptgpd5 - pcpttgpxcotcc5opggptp - gxgxdcddd-od-ope - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_23.txt b/codex-rs/tui2/frames/slug/frame_23.txt deleted file mode 100644 index f573acb714..0000000000 --- a/codex-rs/tui2/frames/slug/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttdotpttotdd - doeteodtdootedepetd - tgteptcpe gxcteoceet - 5cepc5e dccoeeco - 55ee5p t5ttpp5poeo - toeg5e dppppp5ppexoet - eeege 5c5- dee ggepp - x5e e egetdot e p5e - dgo etxcooooocoedpedgod e exe - eoodegog 5eo oedotx ode - 5pe e eggggggg pe5coed5d5e - teede- t5eod5e - o etp5dd d-gcp5t5 - oootcptoxoodttg-dtpe - gxgpooxddtddppe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_24.txt b/codex-rs/tui2/frames/slug/frame_24.txt deleted file mode 100644 index 92833e8c58..0000000000 --- a/codex-rs/tui2/frames/slug/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dxdcttpootd - tgd5geddxoottdpt - tgpco5tgx -ecpxppt o - tp55t5eotoex5egdpoeodp - t 55o5t egg5odeeeoetp - xtotoe ptt5g-ecg5go - e exg t5dt5g5doeoded - e oee eto5odexd5-eee - e- eccccttxxxttdptde e ee - d 5 gpe5ttcctte-dotpe epe - o poeopgggggge ot-poeo5te - o otpo egg5c5 - o-poee- dogd5cdp - --ptodgxxcoocedd5e - pcdptcoddootp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_25.txt b/codex-rs/tui2/frames/slug/frame_25.txt deleted file mode 100644 index d8b8655dac..0000000000 --- a/codex-rs/tui2/frames/slug/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dtopcttttd - tgptpedcoepeet - 5e55ttg-etoooeeed - etpe 5g oe goetpo5 - tddot5pdc5deg e55o5p - otxexdpt-dec 5ete55et - e5epe edd5od5eo5dgeoe - eee5e-ggxdoo5eodxoeeo - dtoegeddooootxeooetpeo - 5-ge5etedeecpdeopo5oe - oxp5oeegggggpt5eoe5ee - edgectco-tpcd5t55e5 - dededodpc5td5dee5 - o-coodpoeppgpep - xtgpottdtep - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_26.txt b/codex-rs/tui2/frames/slug/frame_26.txt deleted file mode 100644 index 4be73d44de..0000000000 --- a/codex-rs/tui2/frames/slug/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dcpppgtt - 5pec5oetcet - 5pggecoeoggot - cpgteexdeedt5d - edtoe-xgpo5ceoc - o ge5c5gpdogoo5 - eg ppoee5eeccgt - 5- oeeeddoee5ee - e -opctxtoo5oee - g -de5teddtpoope - eeg5epgot5etoee - odxe5o 55geee - p peo de5e5t - egpoogpdppc - o5cdpxdop - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_27.txt b/codex-rs/tui2/frames/slug/frame_27.txt deleted file mode 100644 index f333909d2b..0000000000 --- a/codex-rs/tui2/frames/slug/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - cppptt - ecc5e5o - cpe pe5pe - exxdeecex - e eed-po - xd-dgeeeee - o geedpeeg - e eeexogee - e- -geteeee - po -gdedpee - e- ddppt5p - eetteed5e - eootot5ed - oddeoo55 - pog do5 - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_28.txt b/codex-rs/tui2/frames/slug/frame_28.txt deleted file mode 100644 index 3c0deb542c..0000000000 --- a/codex-rs/tui2/frames/slug/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - 5ppc - etdee - o cee - e-epe - e xe - e -ge - dex de - e-gge - 5o de - ee-cxe - e de - eotoe - eopee - pdd5e - x -te - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_29.txt b/codex-rs/tui2/frames/slug/frame_29.txt deleted file mode 100644 index 0c6277f4d5..0000000000 --- a/codex-rs/tui2/frames/slug/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tppppt - tep5gpo - dtee pge - eeeedot5 - o xge e - etxee dd - eooeee d - eeoxe - eexpe e - deoee e - pee5ed o - e5xxe de - xeeeexde - eoep5gep - xep -t - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_3.txt b/codex-rs/tui2/frames/slug/frame_3.txt deleted file mode 100644 index b1e9173608..0000000000 --- a/codex-rs/tui2/frames/slug/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-octtooootd - dtt5oeetegooecddeptd - tc5pcepgge egxgppt5det - 5t5oecttd eopgeeo - p5pe5d5eepod odoeed - 5eoo5 -teocoo e ooe - op e ppoedget -p5et - t-ete t-eg5oe xdeoe - -gpe ceptxep-xottdddttdxdgce - tocot -5p5cce epeddtgo-tcoeeoee - pde e geettg gpppgggppt555t5 - ppx ot e 5dtd55 - od55pot ttc5gtep - odttdppdococtcopcedtg - eptpdcxddddxc5dg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_30.txt b/codex-rs/tui2/frames/slug/frame_30.txt deleted file mode 100644 index 9dfd28bc20..0000000000 --- a/codex-rs/tui2/frames/slug/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dcgpptt - d5ceeoppoo - gpdetooetto - eeoe5edoeo ot - opeeeoetet e - epedt peeee-e - o5eeeod o5oe oe - ge ptoxtege- e - gedgoxoxo5et e - geeoeddxegtt e - goeecodpxeetxe - ep55t5poeeg e - oeoee5pxetx5 - tdttod5et5p - o--edddcp - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_31.txt b/codex-rs/tui2/frames/slug/frame_31.txt deleted file mode 100644 index 1dba8edd8f..0000000000 --- a/codex-rs/tui2/frames/slug/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-cptptot - dtpcttdgtoppt - teo55tode-gedpo - tx5tddpcdtooeoxeo - deeeet-podtgoe5dd - cedexdepgocpt-5etge - 5ee edpeo-o5cpepe5e - oot exgeo edggexo-e - eotdepdxex5txxed e - geex55eedddodeoee p - dpd5tet 5pppe5epxdg - eeeooot dop e - cepodgt - epe5e - ceoe5deetegtee - pgoxdtp5-cp - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_32.txt b/codex-rs/tui2/frames/slug/frame_32.txt deleted file mode 100644 index 33160e7163..0000000000 --- a/codex-rs/tui2/frames/slug/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - tttttccxtd - cpxppxoeeppext - 5dpodttdc-epepoppt - p55edtec- - tdoettgt - 55etepoppec petxo5opot - o5ootopeeceetd et5 p - pegctepoeettetoe5d55epd - eeetd gdeeg5pec-dgoegge - oee-d pdegddetttxxxoegoe - pggdeepopodddddeepecx-d - topee5 epggpppdc555-5 - otedoo toe5px - pppeextd d5de5 t - o-ogxtecopedtgd5 - xcoddtt5pdgg - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_33.txt b/codex-rs/tui2/frames/slug/frame_33.txt deleted file mode 100644 index ff8827f3d2..0000000000 --- a/codex-rs/tui2/frames/slug/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttototox-d - dtpeeopoxce-epep- - d5oetpttoge gp5eetgt - to5e5detd peeodp - d-e5eee5odo etood - 55t odooeot 5o5do - eet g otpedeo epee - pett tppo5ot ogep - eee e te 5ptttcootxxt5deox - d5epg5ct5exedddddeepdddxe- - ooot e5e5 5 ggggppp eet5de - otoptgt 55c5o5 - pogptpct dtet5pop - oxgpotetctdgctopxp - goottddddtpc-g - eeee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_34.txt b/codex-rs/tui2/frames/slug/frame_34.txt deleted file mode 100644 index 4b1eb6a5a2..0000000000 --- a/codex-rs/tui2/frames/slug/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dtottttoxtd - dtpt5ocegxtpoppctod - d55tepgcxpe edootgppt - t5depd5td petpoo - 55eo-5egeggt oopod - t5oee5 oogeopo otoeo - epdxd podpedd ecxd - oeogc epde5ge 5 do - tp5-g 5o55gdoottttttxddg xde - eeot5ttdg55pxeedx-dddt5deeeeo - 5dot eoepdg gppeeeed t5toep - 5tocood 5c-e5p - oteetoct dtogd5te - -o5xgettcttopopetpcp - gxocodddddcopod - eee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_35.txt b/codex-rs/tui2/frames/slug/frame_35.txt deleted file mode 100644 index f2432dc0ad..0000000000 --- a/codex-rs/tui2/frames/slug/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - dttcotttottd - tpop-xpptgepxcgxpod - d55ectpedpge egpop-gept - t55t5tctd ptdoed - 55xe55o gopt eopet - c5c5te pedg--pd eooe - o g-5 oo ppd- edoo - xexc 5ooc5p 5 g5 - 5 td tee5cg5eoodcdotxx exop - etdcp 55cgpt5ec otdddd5gx5p ep - 5eotp5ode5de egddddpddp55dte - g-do5x d5p td - ocedctct tc5dppp - gddgxpx-cxxtxc5pgd5cg - gdxcodd5ddttpgd - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_36.txt b/codex-rs/tui2/frames/slug/frame_36.txt deleted file mode 100644 index c84a104e4a..0000000000 --- a/codex-rs/tui2/frames/slug/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtottttottd - doggot5c5totcttgpptd - topottp-pgee egpxptetpet - degptdddd ppxoge - t5dcopeoeot- do-p - 5 t5e pd ge5t godp - e cge go goo edet - eeox do d55g oe e - epge 55 tpgptttdtttttd eoxe - dpeo tedd5x5 gexdddddddee o5pe - p peo tdt5d gppdddddg etg5 - ptgoc- t5eg5 - o5eetxt tttg5te - ptdgppodcxdtxcg-gtctp - ept5xdttdttttppg - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_4.txt b/codex-rs/tui2/frames/slug/frame_4.txt deleted file mode 100644 index 2eed2c8465..0000000000 --- a/codex-rs/tui2/frames/slug/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-octootottd - d-gtpe5geoo5pceg5ptd - tpoeeetpge edxodpe5ood - 55eteg-tt geppopo - 5e5deoeocdet ogoepo - 5eede pdee5po p ooet - goece pppotget t gce - e-o te5pdee eee - deged ce5gd55txtttddddtt eep - eo5ge t555tdeeooet-dtc5dce55ge - eecpotooto5 gppppppppd-eeee - teogede tdc5ppp - ootcdgtd d-dtpce5 - xeeodppoccocttoptoepe - pooxcxdddddc-pe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_5.txt b/codex-rs/tui2/frames/slug/frame_5.txt deleted file mode 100644 index e0c7693a9e..0000000000 --- a/codex-rs/tui2/frames/slug/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-occtooottd - dtgt5p5eetxotcdegtt - pd5otepge gdoepogtpt - te5e5e-ot -deeeed - to5p5ddtedot e oeoed - xeee5 odeoc5ed t5ee - e5egt eodeoget ppxtet - e5edg tdoe5de tede - etedp 5degtepodxtxdddttdge-e - doeott5tpg egg55oodto-t5e5pee - oteetxtoo5te gppgggggtxceo5 - oot5oo e 5d5ptd - d5poeotd dottppcp - depetptocxodttopdxcp - d-pdpgddd-dttpe - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_6.txt b/codex-rs/tui2/frames/slug/frame_6.txt deleted file mode 100644 index d5ac091f39..0000000000 --- a/codex-rs/tui2/frames/slug/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-occtoottdd - tdc5peoptettcdtptd - ttteeepg egx-optp5od - 5tepepdot oteeeet - poeeepootpo -ooeot - c-5e5edtceodet -oo5e - d txe gdoe5do dtede - x exe deox5ee xtoee - d-e-e 5peepc5tdxtxddttd-o5e - eeot5dddct e5opteetcoooeteog - 5 e 5xeec5g gpgggppgeoo5e - t c 5te tcd55ee - -eodppt dtdd5ptt - egxptgtgcxddttppgccp - d-cpttdddedttp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_7.txt b/codex-rs/tui2/frames/slug/frame_7.txt deleted file mode 100644 index 02d1f1ae52..0000000000 --- a/codex-rs/tui2/frames/slug/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - d-xcptoottd - tpetoetptooocgept - p teeepe edxegp5dpt - ooeeexct dxope5t - epepeede5ot - peeo - e ceeg epoceo t-eee - eoge ddeoeoe ppdee - dg5xe ot5epee p eee - 5gxp tgd5eo5xxccdxxocpoee - etpeeocxe5pe-oeeoototcoeoe - o od5pepd5 ppppppggd5ee - pd 5d5de tg-5c5p - pcttoood tdxtp5pp - odpoepocxdddtpept5 - gxpgxcxddtdcpp - \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_8.txt b/codex-rs/tui2/frames/slug/frame_8.txt deleted file mode 100644 index d028ab360e..0000000000 --- a/codex-rs/tui2/frames/slug/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ddtdcttottd - tgp5eeepoogx gt - deote55pgtgx5xpotdt - dpop5ette p odeeo - p 5e5toe5o -poeet - epteodddoedp oteee - epeee pdcoeee ede5et - otee5 eto5etp- -e5ee - o oeedd g5poex5tttttoxee - g op5odegteteeceoddeeop - 55ooeoeee5pdpdpgopg5oe - ptgeoeee -t555p - podpoood d5odet5p - -cpetpgcctpc5otee - xxdppoedtt5oe - ee \ No newline at end of file diff --git a/codex-rs/tui2/frames/slug/frame_9.txt b/codex-rs/tui2/frames/slug/frame_9.txt deleted file mode 100644 index 2481e07a35..0000000000 --- a/codex-rs/tui2/frames/slug/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - -odp5ttot - 5pd5eepgogd-od - 5 tee5pddo5godxt - g ee5cod ddteodett - 5pgcopgept p-ptctee - e eeegdeocdd epepeee - e xpoeppeootg-t5 eee - e x5dtoxeed5oode gee - g gteg 5egexxetexteee - edeeedtededeeeeddeee - g ed5ooe5gppppeeg5oe - oxeeote t5 d5ee - otoeoo 5cdce5p - d-godep5toccpee - p-d pooect5g - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_1.txt b/codex-rs/tui2/frames/vbars/frame_1.txt deleted file mode 100644 index 0ca3a5d334..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_1.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▎▋▌▉▉▌▌▉▊▎ - ▎▌▊▋▉▍▉▋▉▍▌▏▏▌▎ ▎█▉▎ - ▊▏▉▏▉▉▉█▍▎ ▎█▉▎█▏▌▏▏▏▉ - ▌▉▎▍▉█▊▊▎ ▋▉▏▌▏▊ - ▍▍▌▋█▍▏▍▎▍▍ █▋▏▍▍▊ - ▏▉ ▉▎ ▏▉▋▌▏▏▊ █▋▍▏▏▊ - ▉▍█▊▉ █▍▏▉▋█▏▎ ▏▋▏ - ▏▏▎▏▎ ▊▋▋▏▌▏▉ █▎▏ - ▏▌▏█▎ ▌▏▏▍▍▏█▋▏▉▉▉▉▉▉▎▉▊ ▌█ ▏ - ▎▏▌▉ ▎▌▉▎ ▋▉ ▏█▏▎▎▎▋▋▊▊▊▏▋▊▋▊▏ - ▍▍▎█▍ ▍▍▊▋▋▎ ▎▍▉██▉ ▍▉█▋▊▌▎▋ - ▉▍▊ █▊ ▎ ▊█▋▉▎▏ - ▍▍▊▎▍▉▎ ▎▌▎▉▏▎▉█ - ▍▉▊▍▎ ▉▉▋▌▌▌▌▋▌▉▉▎▊▏▉ - ▎▉█▉▏▏▏▎▎▎▊▎▌▉▉█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_10.txt b/codex-rs/tui2/frames/vbars/frame_10.txt deleted file mode 100644 index b422fb1274..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_10.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▉▉▉▉▍▉▉▎ - ▉█▎▉▉▉▍▏▋▎▉▊ - ▍▎▊▏▏▋▏▏▊▏▉▍▊▊ - ▏▎▎▋▍▏▌▎▋▍▊██▍▍▊ - ▋ ▌▏▏█▏▍ █▊▋▎▋▏▋ - ▏▎ █▏▉▏▋▋▍ ▎▎█▏ - ▍ ▏▉█▉▏▏▏▏▉ ▏▋▊ - █ ▏▏▍▉▋▉▏▏▊▎▎▋▍▏▏ - █ ▏▍ ▍▍▏▌▏▏▉▍▉▌▏▏ - ▏ ▊▏▍▊▏▎▋▎▉▎▎▎▏▉▎ - ▊ █▏▏▏▏▏██ ▍▊█▍▏▎ - ▍▎█▊▍▊▉█ ▎▏██▊▋▋ - ▏█ ▏▏▏▏▉▊▋▊▋▏▋▎ - ▌▎▉▉▋▏▉▌▎ ▋▋█ - ▉▊ ▎▉▍▎▊▌▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_11.txt b/codex-rs/tui2/frames/vbars/frame_11.txt deleted file mode 100644 index 5d4524e293..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_11.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▊▉▉▉▉▉▉▎ - ▋█ ▌▏▏▏▍▍▊ - ▋█▎▎▏▏▌▍▉▋▍▊ - ▏▎▎▏▏▌▌▊▎▌▋▋ - ▎█▋▌▍▏▉▍█▌▎▏▏▊ - ▏▉▎▏▏▉▎▌▌▏▎▋▏▏ - ▉▉ ▊▏▏▏▏▏▏▎▏▌▍ - ▏ ▏▏▋▏▏▍▋ ▏█▏ - ▋ █▏▋▏▏▉▋▉▏▎▏ - ▉▉ ▎▏▋▌▊█▋▏▎▏▍ - █▎▊ ▏▏▏▏▌▋▏▍▍▏ - ▍▎▊▋▏▎▉▋▉▉▌▏▎ - ▎ ▉▏▍▍▍▎▏▌█ - ▍▉ ▉▏▏▊▎▋▉▎ - ▍▎ █▉▉▎▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_12.txt b/codex-rs/tui2/frames/vbars/frame_12.txt deleted file mode 100644 index f81900edb1..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_12.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▉▋ - ▊▌▎▎▋▏▊▍ - █ ▏▋▍▉▊ - ▋▉▏▏▏▌▌█▏ - ▏ █▏▏▏▏▋▋▏ - ▏█▋▋▉▍█▎▏█ - ▏ ▏▋▍▏▏▍ - ▏ ▋▉▍▏▏█ - ▉▊ ▏▋▏▏▉▏▏ - ▏ ▋▏▉▌▉▎ - ▏▋ ▏█▌▉▉▋ - ▊ ▉▏ ▎▊▏ - ▋ ▉▉▏▊▉▍▏ - █▍▋█▊▏▍▏▎ - ▍ █▏▍▋▋ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_13.txt b/codex-rs/tui2/frames/vbars/frame_13.txt deleted file mode 100644 index 4231032a45..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_13.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▋▉▉▉▊ - ▏▎▎▏▏ - ▏▎▎▏█ - ▏▉▉▏▎ - ▉ ▏▏ - █▋▊▏▏ - ▊ ▏▏ - ▉ █▏ - ▋▉ ▎▏▋ - ▏█ ▉▌▏ - █▎▋▋ ▏▎ - ▏▎▎▎▏ - ▏▉▊▏▎ - ▉▎▎▏▌ - ▊ ▋█▌ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_14.txt b/codex-rs/tui2/frames/vbars/frame_14.txt deleted file mode 100644 index 6eab794e0a..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_14.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▌ - ▊▋▎▎▋▍ - ▏▎▏▏▋ ▏ - ▏▏▋▏█▍▏▊ - ▏▏▏▍ ▏ - ▏▉▏▏ █▏ - ▏▏▏▏ ▋ ▉ - ▏▏▍▏▎ ▍ - ▏▏▏▏ █ - █▍▋▋ ▌▋ - ▍▏██▊▋▊▏ - ▏▉▏▏▎▎▎▏ - ▋ █▏▌▌▌▎ - ▏▍▋▉▎▎▋ - ▎▉ █▌▋ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_15.txt b/codex-rs/tui2/frames/vbars/frame_15.txt deleted file mode 100644 index fa9a859bd0..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_15.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▉▏▎ - ▎▊▌▏▏▍▋▉▊ - ▋▋▏▉▌▍▏█▉▏ - ▏▋▎ ▋▋▊▋▎▎▏ - ▏▏▌█▌▍▏▏▋█▍▏ - ▍▏▏▊▏▏▋▋▏ █ - ▏▏ ▏▏▏▏▏▏▊▏ - ▏▏▋▏▏▉▏▏▏ █▊▏ - ▏▏▉▊▊▉▏▏▏▎▋▋▏ - ▏▎▏▌▋▏▍▏▉▋ ▋▏ - ▏▋▏▏▏▏▎▏ ▎ ▌ - ▏▉▉▋▍▏▏▍▊ ▌ - █▏▍▊ ▋▋▋▎ ▌▎ - ▏▍▏▏▍▍▋▉▍▋ - ▍▍▉▍▎▎▎▋ - ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_16.txt b/codex-rs/tui2/frames/vbars/frame_16.txt deleted file mode 100644 index 1fcc2090a2..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_16.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌▉█▉▉▉▏▎ - ▋▍▏▏▉▏▏▍▏▌▍ - ▏▋▋█▍▋▌▍▏▏ █▍ - ▋▋▋ ▌▉▎▍▏▍▏▏▉▉▏ - ▏▏▌▎▋▉█▌▏▏▉▋▏▎▍ - ▏▏▏▉▏█ ▏▍▌ ▏ ▏▋▏ - ▍▍▏ ▋▎▊▏▏▏▏▋▉█▊ ▏ - ▏▋▌ ▏▎▏▏▋▏▏▏█▌ ▎▏ - ▍▍▏▏▏▉▏▋▍▏▏█▉▉ ▋ - ▉█▎▎▊▌▌▍▉▏▋▎▏ ▊▎▏ - ▏▏▏▉██▋▉▍▏▌█▌▋▏▏▎ - ▏▏▉ ▏▎ ▎▋▋▊ ▋ - ▍▏▉ ▏ ▋▊▋▎▊▏ - ▍▏▏█▎▉▏▋▋▉▎▏ - █▋▋▎▌▋▎▎▌▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_17.txt b/codex-rs/tui2/frames/vbars/frame_17.txt deleted file mode 100644 index 1adf01af90..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_17.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▊▌▉▉▉▌▌▏▊▎ - ▎▌▉▉▏▏▏▉▌▉▏▊█▍▊ - ▊▉▍▋▎▌▌▎▉▊█▏▏▏▎█▍ - ▋▏▋▎▋▏█▎▎ █▏▌▍▏▏▏▌▍ - ▏▋▎ ▏ ▏█▋▍▌▍▋▋ ▊ - ▎▌▉▊▋▎ ▊▋▋▏ ▋▊▌▋▏▋▋ - ▉ ▌▋▏ ▊▋▋▏▎ ▉█▏▏▋▌▏ - ▊█▌▏ ▋▋▏▏▋▏▊▏▏▌▌▉ - ▊▋▉▋▍▌▌▉▏▍▎▏▍▍▋▍▏▏▏ ▏ - ▏█▎▌▎▎▏▋▉▍▋▏▏▍ ▍▉█▉▍ - ▍▍▍▋███▉▉▉▉▍▎▏▉▋▋▍ ▍█ - ▍▏▏▎▉ ▎▋▏▏▎ ▌ - ▎▍▍ ▉▍▎ ▊█▊▋▍▎▋ - ▊ ▏▉▉▌▍▉▎▎▌▉▋▏█ - ▎▉▌▋▎▊▉▏▉▎▉▉ - ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_18.txt b/codex-rs/tui2/frames/vbars/frame_18.txt deleted file mode 100644 index 9c46c64821..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_18.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌▌▉▌▌▉▌▏▊▎ - ▎▉█▎▌▊▉▉▏▎▎▊█▉▌▌▎ - ▎▋▋▏▉█▉▌█▊█▋▎█▉▋▋▍ ▉ - ▊▏▏▉▋▊▍█▉ █▍▎▎▋▌▏▍▌▍▍▍▊ - ▎▍▋▋ ▋▏ ▎█▉▎▍▋▏▋▏▎▍▌▍▍▎ - ▋▍▏ ▊▊▏▋▎▎▎▋▊▉▊▏█▊▋▍▋▎▋▏ - ▏▏▍▋▋ ▉▋▉▋▏█▎▋█▉▏▌▏▌▊ - █▍▊▏▏ ▍▍█▏▏▌▍ ▏▌▏█ - ▍██ ▊▍▌▌▌▌▌▉▏▏▊▏▉▋▍▊ ▏▉▏▎ - ▎▍▍▏▉▍▉ ▏▏▋▎▎▍▎▏▏▏▎▌▏▎▍ ▊ - ▏▋▏▍▎██▉▉▉▉▉▎ ▍▎▎▋▏▋▉▋▎▊▎ - ▍▍ ▊▍▊ ▎▎▉█▋▉▎█ - ▍▍▉▉▍▍▋ ▎▉▍▎▋▍▊▎ - █▏▋▉▉▉▉▌▌▌▍█▉▎▋▎▊▉ - █▏▏▎▋▎▎▊▋▉█▌▉█ - ▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_19.txt b/codex-rs/tui2/frames/vbars/frame_19.txt deleted file mode 100644 index 572f5ffc32..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_19.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▌▌▉▏▉▌▌▏▋▎ - ▎▉▏▏▏▍▋▌▌▌▎▉▎▊▏▉▊▊ - ▊▉▌▌▋█▏▉▉▎ ▎▉▋▊▎▉▌▍ - ▋▌▏▉▊▍▎ ▎▌▌▉▊▍▏▉▊ - ▏▏▋▎▋▎ ▊▏▍█▋█▏▍ ▏▍▊ - ▏▍▋▉█ ▎▏▉▏▋ ▋▋ █▋▉▏ - ▍▌▏▏█ ▋▉▍▋▏▉▊▎ ▋▋▋▉▏ - ▏▏▏▏ ▏▌█▍▏█▊ ▏ ▏▏▏ - ▏▉▏▍▊▌▌▌▌▌▌▏▏▏▏▉▉▌▉▏▌ ▍ █▏▏ - ▍▌▋▊▏▌▉▋▍▉▉▍▊▎▊ ▍▎ ▋▍▎▍▊▋▋▏▎ - █▍█▋▉▏█████▉▉█ ▍▍▎▋▏▋▋ ▋▋█ - ▍▍▍▉▎▍ ▎▋▋▎▋▋█ - █ ▎▍▏▏▉▊ ▊▌▌▋▋▏▌▎ - ██▋▏▉▋▏▉▎▎▌▌▋▉█ ▌▉▏█ - █▏▋▎▋▊█▎▊▋▍▉▊▍ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_2.txt b/codex-rs/tui2/frames/vbars/frame_2.txt deleted file mode 100644 index 0e0c021f43..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_2.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▎▋▌▉▌▌▌▉▊▎ - ▎▉▊▋▉▌▉▏▏▏▌▏▏▌▎█▏▉▉▎ - ▊▏▉▏▍▉▉▏▉▎▎ ▎█▉▌▉▋▏▏▌▏▊ - ▎▏▋▎▋▉█▊▊▎ ▊▊▍▏▋▏▊ - ▊▍█▋▉▍▏▍▎▍▍▎ ▍▊▏▍▏▊ - █▋▉█▎ ▏▉▍▉▋▍▉ ▍▍▏▉▏ - ▊▏█▉▏ ▍▋▏▌▏▎▏▊ ▋█▏█▏ - ▏█ ▍▎ ▊▏▉▏▏▌▉ ▏█▋▋▏ - ▉██▌▏ ▌▏▍▍▎▏█▋▏▉▉▉▉▉▉▉▉▊▏▎▏▏▉ - ▎▌█▏ ▋▏▏█ ▋▉ ▏▌▍▎▎▎▎▋▋▎▎▏▉▋ ▏ - ▍▍▍ ▉ ▉▍▋▋▏█ ▎█▉▉▉▉▉▉▉█▏▊▉▏▏ - █▍▎▋▋▊ ▎ ▊▉▌▋▊▉ - ▋▍▎▎▏▉▊ ▊▌▎▉ ▎▏█ - ▎▏▍▌▎▎█▉▉▋▌▌▌▌▋▌▉▎▎▏▏▉ - ▎▉▉▉▉▏▏▎▎▎▎▏▌▋▉█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_20.txt b/codex-rs/tui2/frames/vbars/frame_20.txt deleted file mode 100644 index 42c288df92..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_20.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▉▌▏▏▌▉▌▊▎▎ - ▊▌▌█▌▊▎▊▌▌▌▏▏▎▍▉▍▉▊ - ▊▊█▎▉▉█▏▉█ ▎█▍█▍▉▉▏▉▎ - ▎▏█▎▉▏▉ ▎▊▉▏▍▋▉▍▍ - ▎▏ ▊▏▉ ▎▋ ▎▏▏▍▉▍▏▏▍ - ▋▎▊▏█ ▉▉█▌▏█▋ █▍▏▏▍ - ▌▏▌▉▏ ▊▉ ▋▉▉▍ ▊▍▏▋ - ▏▎▍▋▎ ██▌▋▏█▊ ▏▏█▏ - ▏▋▏▏▌▊▌▌▌▌▌▌▌▌▎▊█▌▍▍▍▍▉ ▋ ▏▍ - █▏▍▏▎█ ▋▎▎▎▎▎▎▏▍ ▉▍▍█▏▌▍ ▊▋ ▏▏ - ▍▊▍▍████████▉█ ▍▊▌▏▏▊▏ ▋▍ - ▉ ▍▉▋▊ ▎▋▉▋▏▋ - ▍▎▍▉▊▏▎ ▊▌▉▎▌▉█ - ▍▊▊▋█▏▉▍▌▌▊▋█▏█▎▊▋▉█ - ▎▉▉▏▎ ▌▎▎▎▎▏▉██ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_21.txt b/codex-rs/tui2/frames/vbars/frame_21.txt deleted file mode 100644 index aa5d4f7274..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_21.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▊▌▉▌▌▉▌▉▊▎▎ - ▊▉▉▎▎▎▎▉▏▌▏▉▌▎▎▊▉▉▎ - ▌▉▍▎▉▉▍█▎ ▎▍▏▉▉▎▎▉▊ - ▊▉▏▌██ ▎▊▌▉▊▌▉▊ - ▋▊▋▉▎ ▊▏▋▎▍ ▍▍▎▍▊ - ▋█▋▏ ▊▉▉▎▋▋ ▍▍▍▊ - ▏█▏▎ ▎ ▋▎▊▋█ ▍▌▍█ - ▎▍ ▍▌ █▏▊ ▏ ▏ - ▏ ▏ ▊▉▌▌▌▌▌▉▉▉ ▍▏▊█▏▊ ▌▋ ▏ - ▏ ▏▍ ▏▎ ▎▊▏ ▍█▎▉▏▊ ▋█▋▋ - ▌ █▋ ▎▎███ █▎ █▉▎▊▌ ▋▎ ▊ - █▉ ▋▌▎ ▎▉█ ▋ - ▉▎ ▍▊▎ ▎▋▌▉█ ▊▎ - ▉▌▎ ▉█▉▉▋▏▏▌▋▌▉██ ▊▎ - ▉▉▉▌▉▎▎▎▎▊▎▌▉▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_22.txt b/codex-rs/tui2/frames/vbars/frame_22.txt deleted file mode 100644 index 3b1ce4ecde..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_22.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▌▉▌▌▉▌▉▊▎ - ▎▉▋▉▏▋▎▋▏▋▍▏▏▌▏▉▋▉▉▎ - ▊▌▉▌▏▍▉▉▎ ▎█▉▍▉▉▉▎▎▉▊ - ▌▉▏▉▉█ ▊▊▏▏▏▎▉▍ - ▍▏▏▋█ ▊▎▋▏▌▏▋▋▌█▏ - ▍▏▉▋▎ ▊▏▋▏▌▍▋▎ ▍▏▍▌▏ - ▏▉▉▍▋ ▏▉▏▍▉▋█ ▏▏▋▏ - ▏▏▌▏▎ ▏▍▏▏▍▍ ▏▍▏█▏ - ▏▏▊█▊ ▉▏▌▌▌▌▌▌▉▉▎▍▉▏▎ ▍▊ ▎▏▌█ - ▍▏▌ ▉▎▍▍▊ ▋▊█▏▋▋▏ ▍▉▌▎█▏▊▊ ▋▍▌ - ▍▍▍▎▏ █████▉▉█▎ ▎▋▉▉▎▋▋▋█▊▎ - ▍▉▍▎▍▊ ▎▉▊█▏▊█ - ██▉▌ ▉▉▎ ▎▋▉▊█▉▎▋ - █▋▉▉▊█▉▏▌▌▉▌▌▋▌▉███▊▉ - █▏█▏▎▌▎▎▎▊▌▎▋▌▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_23.txt b/codex-rs/tui2/frames/vbars/frame_23.txt deleted file mode 100644 index 0b99396129..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_23.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▎▌▉▉▉▉▌▉▎▎ - ▎▌▏▉▏▍▎▊▎▌▌▊▏▎▏▉▏▉▎ - ▊█▉▏█▊▌▉▎ █▏▋▉▏▍▌▏▏▊ - ▋▌▏▉▌▋▎ ▎▌▌▍▏▏▌▍ - ▋▋▏▎▋█ ▊▋▊▊█▉▋▉▍▏▍ - ▊▍▏█▋▎ ▎▉▉▉██▋▉█▏▏▍▏▊ - ▎▏▏█▏ ▋▋▋▋ ▎▏▎ █ ▏█▉ - ▏▋▏ ▏ ▏█▏▊▎▍▊ ▏ ▉▋▏ - ▍█▍ ▏▉▏▌▌▌▌▌▌▌▌▏▎▉▏▎█▍▎ ▏ ▏▏▏ - ▏▍▍▎▏█▌█ ▋▎▍ ▍▏▎▍▊▏ ▍▎▏ - ▋█▏ ▏ ▎███████ █▎▋▌▌▏▎▋▍▋▎ - ▊▏▏▎▏▋ ▊▋▏▍▍▋▎ - ▍ ▏▊█▋▎▎ ▎▋█▌▉▋▉▋ - ▍▌▍▉▌▉▉▍▏▌▌▎▉▉█▊▎▉▉▎ - █▏ ▉▌▍▏▎▎▉▎▎▉▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_24.txt b/codex-rs/tui2/frames/vbars/frame_24.txt deleted file mode 100644 index 5e26d7a27b..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_24.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▏▎▌▉▉▉▌▌▊▎ - ▊█▎▋█▏▎▎▏▌▌▉▊▎█▉ - ▊██▌▍▋▉█▏ ▋▎▋▉▏▉▉▊ ▍ - ▊█▋▋▊▋▏▌▊▍▎▏▋▎ ▎▉▍▏▍▎█ - ▊ ▋▋▍▋▊ ▎██▋▍▎▏▎▎▍▏▊█ - ▏▊▍▉▍▎ ▉▊▊▋ ▋▏▌ ▋█▍ - ▏ ▏▏ ▊▋▎▊▋█▋▍▍▏▍▍▏▍ - ▏ ▍▏▏ ▏▊▌▋▍▎▏▏▎▋▋▎▏▏ - ▏▋ ▏▌▌▌▋▉▉▏▏▏▉▉▎▉▊▎▏ ▏ ▏▏ - ▎ ▋ ▉▏▋▊▊▌▌▊▊▏▋▍▍▉▉▏ ▏▉▏ - ▍ █▍▏▍▉██████▎ ▍▊▋▉▍▏▌▋▊▎ - ▍ ▍▊▉▍ ▏ █▋▌▋ - ▍▋▉▍▎▏▋ ▎▌█▎▋▋▎▉ - ▋▊▉▊▍▍█▏▏▋▌▌▌▏▍▎▋▎ - ▉▋▎▉▉▌▍▎▎▌▌▉▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_25.txt b/codex-rs/tui2/frames/vbars/frame_25.txt deleted file mode 100644 index 5009b8b66d..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_25.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▉▌▉▌▉▉▉▉▎ - ▊ ▉▊▉▎▎▌▍▏▉▏▏▊ - ▋▏▋▋▊▊█▋▏▉▍▌▍▏▏▏▎ - ▏▉▉▏ ▋ ▌▏ █▍▏▊▉▍▋ - ▊▎▎▍▊▋█▍▌▋▎▏█ ▏▋▋▍▋▉ - ▍▊▏▏▏▎▉▉▋▍▏▋ ▋▏▊▏▋▋▏▊ - ▏▋▏▉▏ ▏▎▎▋▍▎▋▏▍▋▎█▏▌▏ - ▏▏▏▋▏▋█ ▏▎▌▍▋▏▍▎▏▌▏▏▍ - ▍▉▍▏█▏▎▎▌▌▌▌▉▏▏▍▍▏▉▉▏▍ - ▋▊█▏▋▏▊▏▎▏▏▌▉▎▏▍▉▌▋▍▏ - ▍▏▉▋▍▏▎██████▉▋▏▍▏▋▏▎ - ▏▎█▏▌▉▌▍▊▊▉▋▎▋▊▋▋▏▋ - ▍▎▍▏▎▍▎▉▌▋▊▍▋▎▏▏▋ - ▍▋▌▍▍▎▉▌▏▉▉ ▉▏▉ - ▏▊█▉▍▉▊▎▉▏▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_26.txt b/codex-rs/tui2/frames/vbars/frame_26.txt deleted file mode 100644 index 900a51c3b5..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_26.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌▉▉▉█▉▊ - ▋▉▏▌▋▍▏▉▌▏▊ - ▋▉██▏▌▍▏▍██▍▊ - ▌▉█▊▏▏▏▎▏▏▎▉▋▍ - ▏▎▊▍▏▋▏█▉▍▋▌▏▍▌ - ▍ █▏▋▋▋█▉▎▍█▍▍▋ - ▏ ▉█▌▏▏▋▏▏▌▌█▉ - ▋▊ ▍▏▏▏▍▍▌▏▏▋▏▏ - ▏ ▋▍▉▌▉▏▉▌▌▋▍▏▏ - ▋▎▏▋▊▏▎▎▊▉▍▍▉▏ - ▏▎█▋▏▉█▍▊▋▎▉▍▏▏ - ▍▎▏▏▋▍ ▋▋█▏▏▎ - ▉ ▉▏▍ ▍▎▋▏▋▊ - ▏█▉▍▍█▉▎▉▉▌ - ▍▋▋▍▉▏▎▍▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_27.txt b/codex-rs/tui2/frames/vbars/frame_27.txt deleted file mode 100644 index 0b2e8c7306..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_27.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▌▉▉▉▉▊ - ▏▌▋▋▏▋▍ - ▌█▎ ▉▏▋█▏ - ▏▏▏▍▏▏▌▏▏ - ▏ ▎▏▎▊█▌ - ▏▎▋▎█▎▏▏▏▎ - ▌ █▏▏▎▉▏▏ - ▏ ▎▏▏▏▌█▏▏ - ▏▋ ▋ ▏▉▏▏▏▏ - █▌ ▋█▎▏▎▉▏▏ - ▏▊ ▎▍▉▉▉▋█ - ▏▏▉▉▏▏▎▋▏ - ▏▌▌▉▌▊▋▏▍ - ▍▎▎▏▍▌▋▋ - █▍█ ▍▍▋ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_28.txt b/codex-rs/tui2/frames/vbars/frame_28.txt deleted file mode 100644 index 01ce82b6d3..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_28.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▋▉▉▌ - ▏▉▎▏▎ - ▌ ▌▏▎ - ▏▊▎▉▏ - ▏ ▏▏ - ▏ ▊█▏ - ▍▏▏ ▎▏ - ▏▊██▏ - ▋▍ ▎▏ - ▏▏▋▋▏▏ - ▏ ▎▏ - ▏▌▉▌▏ - ▏▌▉▏▏ - ▉▎▎▋▏ - ▏ ▋▉▏ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_29.txt b/codex-rs/tui2/frames/vbars/frame_29.txt deleted file mode 100644 index c682a6082c..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_29.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▉▉▉▉▉▊ - ▊▏▉▋ ▉▍ - ▍▊▏▏ ██▏ - ▏▏▏▏▍▍▊▋ - ▍ ▏█▏ ▏ - ▏▊▏▏▏ ▎▎ - ▏▍▍▏▏▏ ▍ - ▏▏▌▏▏ - ▏▏▏▉▏ ▎ - ▎▏▌▏▏ ▎ - ▉▏▏▋▏▍ ▌ - ▏▋▏▏▏ ▎▏ - ▏▏▏▏▎▏▎▏ - ▏▌▏▉▋█▏█ - ▏▏▉ ▋▊ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_3.txt b/codex-rs/tui2/frames/vbars/frame_3.txt deleted file mode 100644 index 6c202bc0c3..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_3.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▉▉▌▌▌▌▊▎ - ▎▉▊▋▍▏▏▉▏█▌▌▏▌▎▍▏▉▉▎ - ▊▌▋▉▌▏▉██▎ ▎█▏ ▉▉▉▋▍▏▊ - ▋▉▋▍▏▌▊▉▎ ▎▌▉ ▏▏▍ - █▋▉▏▋▍▋▏▏▉▍▎ ▍▍▍▏▏▎ - ▋▏▍▍▋ ▋▊▏▍▌▍▍ ▏ ▍▍▏ - ▌▉ ▏ █▉▍▏▎█▏▊ ▋▉▋▏▊ - ▊▊▏▊▏ ▊▊▏█▋▍▏ ▏▎▏▌▏ - ▊██▏ ▌▏▉▉▏▏▉▊▏▌▉▉▎▎▎▉▉▎▏▍█▌▏ - ▊▍▋▍▊ ▋▋▉▋▌▌▏ ▏▉▏▎▎▊ ▌▋▊▌▍▎▏▍▏▎ - █▍▏ ▏ █▏▏▉▊█ █▉▉▉███▉▉▊▋▋▋▊▋ - █▉▏ ▍▉ ▎ ▋▍▉▍▋▋ - ▌▍▋▋▉▍▊ ▊▉▌▋█▊▏█ - ▌▍▉▊▎▉▉▍▌▌▌▌▊▋▌▉▌▎▎▉█ - ▎▉▉▉▎▋▏▎▎▎▎▏▌▋▍█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_30.txt b/codex-rs/tui2/frames/vbars/frame_30.txt deleted file mode 100644 index a44dbb6ed0..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_30.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▌█▉▉▉▊ - ▎▋▌▏▏▍▉█▌▍ - ██▍▏▊▍▍▏▉▊▍ - ▏▏▍▏▋▏▎▍▏▍ ▍▊ - ▌▉▏▏▎▍▏▊▏▊ ▏ - ▏▉▏▍▉ ▉▏▏▏▏▋▏ - ▍▋▏▏▏▍▎ ▍▋▍▏ ▌▎ - █▏ █▉▌▏▊▏█▏▊ ▎ - █▏▎█▍▏▌▏▍▋▏▊ ▎ - █▏▏▍▏▎▎▏▏ ▉▊ ▎ - ▍▏▏▌▍▎▉▏▏▏▉▏▏ - ▏▉▋▋▊▋▉▍▏▏█ ▏ - ▍▏▍▏▏▋▉▏▏▊▏▋ - ▊▍▊▉▌▍▋▏▊▋█ - ▍▊▋▏▍▎▎▌█ - ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_31.txt b/codex-rs/tui2/frames/vbars/frame_31.txt deleted file mode 100644 index 70da8799e2..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_31.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▋▉▉▉▉▌▊ - ▎▉▉▌▉▊▎█▉▍█▉▊ - ▊▏▍▋▋▊▌▎▎▋█▏▎▉▍ - ▊▏▋▉▎▎▉▌▎▊▍▍▏▍▏▏▍ - ▍▏▏▏▏▉▊▉▍▎▊█▍▏▋▎▎ - ▌▏▍▎▏▎▏██▍▌▉▊▋▋▏▊█▏ - ▋▏▏ ▏▍▉▏▍▋▌▋▌▉▏▉▏▋▏ - ▍▍▊ ▏▏█▏▍ ▏▍██▏▏▍▋▏ - ▏▍▊▎▏█▍▏▏▏▋▉▏▏▏▎ ▏ - █▏▏▏▋▋▏▏▎▎▎▍▎▏▍▏▏ ▉ - ▍▉▎▋▉▏▊ ▋▉▉▉▎▋▏█▏▎ - ▏▏▏▍▍▍▊ ▎▌▉ ▏ - ▌▏▉▌▎ ▊ ▋ ▏▉▎▋▎ - ▋▏▍▏▋▎▏▎▊▏█▊▏▎ - ██▌▏▎▉▉▋▋▌▉ - ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_32.txt b/codex-rs/tui2/frames/vbars/frame_32.txt deleted file mode 100644 index ddfb4be3fe..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_32.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▊▉▉▉▉▌▌▏▊▎ - ▌▉▏▉▉▏▍▏▏▉▉▏▏▊ - ▋▍▉▌▍▊▉▍▋▊▎▉▏▉▍█▉▊ - █▋▋▏▎▊▎▌▊ ▊ ▊▎▍▏▉▊ ▊ - ▋▋▏▊▏▉▍█▉▏▌ █▎▊▏▍▋▍▉▍▊ - ▍▋▍▍▊▍▉▏▎▋▏▏▉▎ ▏▉▋ ▉ - ▉▏█▌▊▎█▍▏▏▊▊▏▊▌▎▋▎▋▋▏█▎ - ▏▏▏▊▎ █▎▏▏█▋█▏▌▊▎█▍▏██▎ - ▍▏▏▊▎ █▎▏█▎▎▏▉▉▉▏▏▏▌▏█▌▎ - ███▎▎▏▉▍█▍▎▎▎▎▎▏▏▉▏▌▏▊▎ - ▊▍▉▏▏▋ ▏▉██▉▉▉▍▌▋▋▋▋▋ - ▍▊▏▍▍▍ ▊▍▏▋█▏ - █▉▉▏▏▏▉▎ ▎▋▎▏▋ ▊ - ▍▊▍█▏▉▏▌▌▉▎▎▉█▎▋ - ▏▌▌▎▎▊▉▋▉▎██ - ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_33.txt b/codex-rs/tui2/frames/vbars/frame_33.txt deleted file mode 100644 index 7fa5ac29bc..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_33.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▌▉▌▉▌▏▋▎ - ▎▉▉▏▏▍▉▌▏▋▏▊▏▉▏▉▋ - ▎▋▍▏▉▉▉▊▌█▎ █▉▋▏▏▉█▊ - ▊▍▋▏▋▎▏▊▎ ▉▏▏▍▎▉ - ▎▋▏▋▎▏▏▋▍▍▍ ▏▉▍▍▍ - ▋▋▊ ▍▎▍▍▎▍▊ ▋▍▋▎▍ - ▏▏▉ ▍▊▉▏▎▏▍ ▏▉▏▏ - ▉▏▊▊ ▊██▍▋▌▊ ▍█▏▉ - ▏▏▏ ▏ ▊▎ ▋▉▊▊▊▌▌▌▉▏▏▉▋▎▏▍▏ - ▍▋▏██▋▋▊▋▎▏▏▎▎▎▎▎▏▏▉▍▍▍▏▏▋ - ▍▌▍▊ ▏▋▏▋ ▋ ████▉▉▉ ▏▏▊▋▍▎ - ▍▉▍▉▊█▊ ▋▋▌▋▌▋ - ▉▍█▉▊▉▌▊ ▎▉▏▊▋▉▍█ - ▍▏█▉▍▉▏▊▌▉▎█▌▊▍▉▏▉ - █▌▌▉▊▎▎▎▍▉▉▌▊█ - ▎▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_34.txt b/codex-rs/tui2/frames/vbars/frame_34.txt deleted file mode 100644 index a8c447ff18..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_34.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▌▉▉▉▉▌▏▊▎ - ▎▉▉▉▋▍▌▏█▏▉▉▌▉▉▌▊▌▎ - ▎▋▋▊▏▉█▋▏▉▎ ▎▍▍▍▊█▉▉▊ - ▊▋▎▏▉▎▋▊▎ ▉▏▊▉▍▍ - ▋▋▏▍▊▋▏ ▏█ ▊ ▍▍▉▍▍ - ▊▋▍▏▎▋ ▍▍█▏▍▉▍ ▍▊▍▏▍ - ▏▉▍▏▎ █▍▎▉▏▎▍ ▏▌▏▎ - ▍▏▌█▋ ▏▉▎▏▋█▏ ▋ ▎▍ - ▊█▋▋█ ▋▍▋▋█▎▌▌▉▉▉▉▉▉▏▎▎█ ▏▍▎ - ▏▏▍▊▋▊▊▍█▋▋█▏▏▏▎▏▋▎▎▎▊▋▎▏▏▎▏▍ - ▋▎▍▊ ▏▍▏▉▍ █▉▉▎▎▎▎▍ ▊▋▊▍▏█ - ▋▊▍▌▌▍▎ ▋▌▋▏▋█ - ▍▉▎▏▉▍▌▊ ▎▉▌█▎▋▊▎ - ▊▍▋▏█▏▉▊▌▊▉▌▉▌▉▎▉▉▋█ - █▏▍▌▌▎▎▎▎▎▌▌▉▌▍ - ▎▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_35.txt b/codex-rs/tui2/frames/vbars/frame_35.txt deleted file mode 100644 index ba905231e1..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_35.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▊▉▌▌▉▉▉▌▉▊▎ - ▊▉▌▉▋▏▉▉▉█▏▉▏▋█▏▉▌▎ - ▎▋▋▏▌▉▉▏▍▉█▎ ▎█▉▌▉▋█▏▉▊ - ▊▋▋▉▋▊▌▊▎ ▉▉▎▍▏▎ - ▋▋▏▏▋▋▍ █▍▉▊ ▏▍▉▏▊ - ▌▋▌▋▉▎ █▏▎█▋▋▉▎ ▏▍▍▏ - ▍ ▋▋ ▍▍ █▉▎▋ ▏▍▍▍ - ▏▏▏▋ ▋▍▍▌▋█ ▋ █▋ - ▋ ▊▎ ▊▏▏▋▌█▋▏▌▌▎▌▎▌▉▏▏ ▏▏▌▉ - ▏▊▍▌█ ▋▋▌█▉▉▋▏▌ ▍▊▎▎▎▎▋█▏▋█ ▏█ - ▋▎▍▊█▋▍▎▏▋▍▎ ▎█▍▍▍▍▉▍▍▉▋▋▎▊▏ - █▋▍▍▋▏ ▎▋▉ ▊▍ - ▍▌▎▍▌▊▋▊ ▊▌▋▎▉▉█ - █▍▎█▏▉▏▊▋▏▏▉▏▌▋▉█▎▋▌█ - █▍▏▌▌▎▎▋▎▎▉▉▉█▍ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_36.txt b/codex-rs/tui2/frames/vbars/frame_36.txt deleted file mode 100644 index 246ed3d692..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_36.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▉▌▉▉▉▉▌▉▊▎ - ▎▌██▍▉▋▌▋▉▍▉▌▉▉█▉▉▉▎ - ▊▍█▍▊▉▉▊▉█▎▎ ▎█▉▏▉▉▏▊▉▏▊ - ▎▏█▉▉▎▎▎▎ █▉▏▍█▏ - ▊▋▎▌▍█▏▍▎▍▊▋ ▍▍▋▉ - ▋ ▊▋▎ ▉▎ █▏▋▊ █▍▍▉ - ▏ ▌ ▎ ▍ █▍▍ ▏▍▏▊ - ▏▏▍▏ ▎▍ ▎▋▋ ▍▏ ▏ - ▏██▏ ▋▋ ▊▉██▊▉▉▎▉▉▉▉▉▎ ▏▍▏▏ - ▎▉▏▍ ▊▏▎▎▋▏▋ ▎▏▎▎▎▎▎▎▎▏▏ ▍▋█▎ - █ ▉▏▍ ▉▎▉▋▍ █▉▉▍▍▍▍▍█ ▏▊█▋ - █▊█▍▌▋ ▊▋▏█▋ - ▍▋▏▏▉▏▊ ▊▉▉█▋▊▎ - ▉▊▎█▉▉▌▍▌▏▎▉▏▌█▊█▊▌▉█ - ▎▉▉▋▏▎▊▊▎▊▊▉▉▉▉█ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_4.txt b/codex-rs/tui2/frames/vbars/frame_4.txt deleted file mode 100644 index 5dcae750bc..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_4.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▉▌▌▉▌▉▊▎ - ▎▊█▉▉▏▋ ▏▍▌▋▉▌▏█▋▉▊▎ - ▊▉▌▏▏▏▉▉█▎ ▎▍▏▍▍▉▏▋▍▍▎ - ▋▋▏▊▏█▋▉▊ █▏▉▉▍▉▍ - ▋▏▋▎▏▌▏▍▌▍▏▊ ▍█▍▏▉▍ - ▋▏▏▍▏ █▎▏▏▋▉▍ █ ▍▌▏▊ - █▍▏▌▎ █▉▉▍▉█▏▊ ▊ █▌▏ - ▏▋▍ ▊▏▋▉▍▏▏ ▏▏▏ - ▎▏ ▏▎ ▌▏▋█▍▋▋▉▏▉▉▉▎▎▎▎▊▊ ▏▏▉ - ▏▍▋ ▏ ▊▋▋▋▊▎▏▎▌▍▏▊▋▎▊▋▋▍▌▏▋▋█▏ - ▎▏▌█▍▊▍▍▊▍▋ █▉▉▉▉▉▉▉█▍▊▏▏▏▎ - ▊▏▍█▏▎▎ ▊▍▌▋▉▉█ - ▍▍▊▋▍ ▊▎ ▎▋▍▊▉▌▏▋ - ▏▏▏▌▎▉▉▍▌▌▌▌▊▉▌▉▉▍▏▉▎ - ▉▍▍▏▋▏▎▎▎▎▎▌▊▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_5.txt b/codex-rs/tui2/frames/vbars/frame_5.txt deleted file mode 100644 index cab16091cb..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_5.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▌▉▌▌▌▉▊▎ - ▎▉█▉▋▉▋▏▏▉▏▌▉▌▎▏█▉▊ - ▉▎▋▍▉▏▉█▎ █▍▌▏▉▍ ▊▉▊ - ▊▏▋▏▋▏▋▌▊ ▊▍▏▏▏▏▎ - ▊▍▋▉▋▍▎▉▏▎▍▊ ▎ ▍▏▍▏▎ - ▏▏▏▏▋ ▍▎▏▍▌▋▏▎ ▉▋▏▏ - ▏▋▏ ▉ ▎▍▎▏▍█▏▊ ██▏▉▏▊ - ▏▋▏▎█ ▊▎▌▏▋▎▏ ▉▏▎▏ - ▏▊▏▍▉ ▋▎▏█▊▏▉▌▎▏▉▏▎▎▎▉▊▎█▏▋▏ - ▎▍▏▍▊▊▋▉▉█ ▏█ ▋▋▍▌▍▊▌▋▊▋▏▋▉▏▎ - ▍▊▏▏▊▏▊▍▍▋▉▎ █▉▉█████▊▏▌▏▍▋ - ▍▍▉▋▍▍ ▎ ▋▎▋▉▊▍ - ▍▋▉▍▏▌▉▎ ▎▌▉▊▉█▌▉ - ▍▏▉▏▊▉▉▍▌▏▌▎▊▉▌▉▍▏▌▉ - ▍▊▉▎▉█▎▎▎▊▎▊▉▉▎ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_6.txt b/codex-rs/tui2/frames/vbars/frame_6.txt deleted file mode 100644 index e41e013ab0..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_6.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▌▌▌▉▌▌▉▉▎▎ - ▊▍▌▋▉▏▌▉▉▏▉▉▌▎▊▉▉▎ - ▊▊▊▏▏▏▉█ ▎█▏▋▌▉▉▉▋▍▎ - ▋▊▏▉▏▉▎▌▊ ▍▉▏▏▏▏▊ - █▌▏▏▏▉▍▍▉▉▍ ▋▍▍▏▍▊ - ▌▊▋▏▋▎▍▉▌▏▍▎▏▊ ▋▌▍▋▏ - ▎ ▉▏▏ ▍▍▏▋▍▍ ▍▉▏▎▏ - ▏ ▏▏▏ ▎▏▌▏▋▏▏ ▏▉▍▏▏ - ▎▋▏▊▏ ▋█▏▏▉▌▋▉▎▏▉▏▎▎▉▉▎▋▍▋▏ - ▏▏▍▊▋▎▍▎▌▉ ▏▋▌▉▊▏▏▊▌▌▌▍▏▊▏▍ - ▋ ▏ ▋▏▏▏▌▋█ █▉████▉█▎▍▍▋▏ - ▊ ▌ ▋▊▎ ▊▋▎▋▋▏▎ - ▋▎▍▎▉▉▊ ▎▉▍▎▋▉▊▉ - ▎█▏▉▊█▉ ▌▏▎▎▊▉▉▉ ▋▌█ - ▍▊▌▉▉▊▎▎▎▎▎▊▉▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_7.txt b/codex-rs/tui2/frames/vbars/frame_7.txt deleted file mode 100644 index 7a88d5ef14..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_7.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▋▏▌▉▉▌▌▉▊▎ - ▊▉▎▉▍▏▉▉▉▌▌▍▌ ▎▉▊ - ▉ ▊▏▏▏▉▎ ▎▍▏▎ ▉▋▎▉▊ - ▍▍▏▏▏▏▌▉ ▍▏▍▉▏▋▊ - ▎█▏▉▏▏▍▏▋▍▊ ▋ ▉▏▏▍ - ▏ ▌▏▏█ ▎█▍▌▏▍ ▊▊▏▏▏ - ▎▍█▏ ▍▎▏▍▏▍▏ ▉▉▍▏▏ - ▎█▋▏▏ ▌▉▋▏▉▏▎ █ ▏▏▏ - ▋█▏▉ ▊█▎▋▏▍▋▏▏▌▌▎▏▏▌▌▉▍▏▏ - ▏▊█▏▏▌▋▏▏▋█▏▋▍▏▏▍▍▊▌▊▌▍▏▍▏ - ▍ ▌▍▋▉▏▉▎▋ ▉▉▉▉▉▉██▎▋▏▏ - █▎ ▋▎▋▎▎ ▊█▊▋▌▋█ - █▌▊▉▍▍▍▎ ▊▍▏▊▉▋▉█ - ▍▎█▍▏▉▍▌▏▎▎▎▉▉▏▉▉▋ - █▏▉█▏▋▏▎▎▊▎▌▉▉ - \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_8.txt b/codex-rs/tui2/frames/vbars/frame_8.txt deleted file mode 100644 index bbf2016fab..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_8.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▎▎▉▎▌▉▉▌▉▊▎ - ▊█▉▋▏▏▏▉▌▌ ▏ █▉ - ▎▏▌▊▏▋▋▉ ▊█▏▋▏▉▍▉▍▊ - ▎▉▍▉▋▏▊▉▏ █ ▌▍▏▏▍ - █ ▋▏▋▊▍▏▋▍ ▋█▍▏▏▊ - ▏█▊▏▍▍▍▎▍▏▎▉ ▍▉▏▏▏ - ▏▉▏▏▏ █▎▌▍▏▎▏ ▏▍▏▋▏▊ - ▍▊▏▏▋ ▏▉▍▋▏▊▉▋ ▋▎▋▏▏ - ▍ ▍▏▏▎▍ ▋▉▌▏▏▋▉▊▉▊▊▍▏▏▏ - ▍▉▋▌▎▏█▊▏▊▏▏▌▏▌▎▎▏▏▌█ - ▋▋▍▍▏▍▏▏▏▋█▍▉▍▉█▍▉█▋▍▏ - █▊█▏▍▏▎▎ ▋▊▋▋▋█ - █▍▎▉▍▍▍▎ ▎▋▍▎▏▉▋▉ - ▋▋█▏▊▉ ▌▌▉▉▌▋▌▊▏▎ - ▏▏▎▉▉▍▏▎▊▉▋▌▎ - ▎▎ \ No newline at end of file diff --git a/codex-rs/tui2/frames/vbars/frame_9.txt b/codex-rs/tui2/frames/vbars/frame_9.txt deleted file mode 100644 index 4e36e6e126..0000000000 --- a/codex-rs/tui2/frames/vbars/frame_9.txt +++ /dev/null @@ -1,17 +0,0 @@ - - ▋▌▍▉▋▉▉▌▊ - ▋▉▎▋▏▏▉█▌ ▎▊▍▎ - ▋ ▊▏▏▋▉▍▍▌▋ ▍▎▏▊ - █ ▏▏▋▌▌▎ ▎▍▊▏▍▍▏▉▊ - ▋▉█▌▍▉█▏▉▊ ▉▋█▉▋▉▏▏ - ▏ ▏▏▏█▍▏▍▌▎▎ ▏█▏▉▏▏▏ - ▎ ▏▉▍▏▉▉▏▍▍▊█▋▊▋ ▎▏▏ - ▏ ▏▋▎▊▍▏▏▏▎▋▌▍▎▏ ▏▏ - █▊▏█ ▋▏█▏▏▏▏▉▏▏▊▏▏▎ - ▏▎▏▏▏▎▊▏▍▏▎▏▏▏▏▎▎▏▏▏ - █ ▎▍▋▍▍▏▋█▉▉▉▉▏▏█▋▍▏ - ▍▏▏▏▍▊▎ ▊▋ ▍▋▏▎ - ▍▉▍▏▍▍ ▋▌▎▌▏▋▉ - ▍▊█▍▎▏▉▋▉▌▌▌▉▏▎ - █▊▎ ▉▍▍▏▌▉▋█ - \ No newline at end of file diff --git a/codex-rs/tui2/prompt_for_init_command.md b/codex-rs/tui2/prompt_for_init_command.md deleted file mode 100644 index b8fd3886b3..0000000000 --- a/codex-rs/tui2/prompt_for_init_command.md +++ /dev/null @@ -1,40 +0,0 @@ -Generate a file named AGENTS.md that serves as a contributor guide for this repository. -Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. -Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. - -Document Requirements - -- Title the document "Repository Guidelines". -- Use Markdown headings (#, ##, etc.) for structure. -- Keep the document concise. 200-400 words is optimal. -- Keep explanations short, direct, and specific to this repository. -- Provide examples where helpful (commands, directory paths, naming patterns). -- Maintain a professional, instructional tone. - -Recommended Sections - -Project Structure & Module Organization - -- Outline the project structure, including where the source code, tests, and assets are located. - -Build, Test, and Development Commands - -- List key commands for building, testing, and running locally (e.g., npm test, make build). -- Briefly explain what each command does. - -Coding Style & Naming Conventions - -- Specify indentation rules, language-specific style preferences, and naming patterns. -- Include any formatting or linting tools used. - -Testing Guidelines - -- Identify testing frameworks and coverage requirements. -- State test naming conventions and how to run tests. - -Commit & Pull Request Guidelines - -- Summarize commit message conventions found in the project’s Git history. -- Outline pull request requirements (descriptions, linked issues, screenshots, etc.). - -(Optional) Add other sections if relevant, such as Security & Configuration Tips, Architecture Overview, or Agent-Specific Instructions. diff --git a/codex-rs/tui2/src/additional_dirs.rs b/codex-rs/tui2/src/additional_dirs.rs deleted file mode 100644 index 54746c1705..0000000000 --- a/codex-rs/tui2/src/additional_dirs.rs +++ /dev/null @@ -1,83 +0,0 @@ -use codex_core::protocol::SandboxPolicy; -use std::path::PathBuf; - -/// Returns a warning describing why `--add-dir` entries will be ignored for the -/// resolved sandbox policy. The caller is responsible for presenting the -/// warning to the user (for example, printing to stderr). -pub fn add_dir_warning_message( - additional_dirs: &[PathBuf], - sandbox_policy: &SandboxPolicy, -) -> Option { - if additional_dirs.is_empty() { - return None; - } - - match sandbox_policy { - SandboxPolicy::WorkspaceWrite { .. } - | SandboxPolicy::DangerFullAccess - | SandboxPolicy::ExternalSandbox { .. } => None, - SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), - } -} - -fn format_warning(additional_dirs: &[PathBuf]) -> String { - let joined_paths = additional_dirs - .iter() - .map(|path| path.to_string_lossy()) - .collect::>() - .join(", "); - format!( - "Ignoring --add-dir ({joined_paths}) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." - ) -} - -#[cfg(test)] -mod tests { - use super::add_dir_warning_message; - use codex_core::protocol::NetworkAccess; - use codex_core::protocol::SandboxPolicy; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - #[test] - fn returns_none_for_workspace_write() { - let sandbox = SandboxPolicy::new_workspace_write_policy(); - let dirs = vec![PathBuf::from("/tmp/example")]; - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } - - #[test] - fn returns_none_for_danger_full_access() { - let sandbox = SandboxPolicy::DangerFullAccess; - let dirs = vec![PathBuf::from("/tmp/example")]; - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } - - #[test] - fn returns_none_for_external_sandbox() { - let sandbox = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }; - let dirs = vec![PathBuf::from("/tmp/example")]; - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } - - #[test] - fn warns_for_read_only() { - let sandbox = SandboxPolicy::ReadOnly; - let dirs = vec![PathBuf::from("relative"), PathBuf::from("/abs")]; - let message = add_dir_warning_message(&dirs, &sandbox) - .expect("expected warning for read-only sandbox"); - assert_eq!( - message, - "Ignoring --add-dir (relative, /abs) because the effective sandbox mode is read-only. Switch to workspace-write or danger-full-access to allow additional writable roots." - ); - } - - #[test] - fn returns_none_when_no_additional_dirs() { - let sandbox = SandboxPolicy::ReadOnly; - let dirs: Vec = Vec::new(); - assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); - } -} diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs deleted file mode 100644 index f7cade65a1..0000000000 --- a/codex-rs/tui2/src/app.rs +++ /dev/null @@ -1,3061 +0,0 @@ -use crate::app_backtrack::BacktrackState; -use crate::app_event::AppEvent; -use crate::app_event::ExitMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxFallbackReason; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::ApprovalRequest; -use crate::chatwidget::ChatWidget; -use crate::custom_terminal::Frame; -use crate::diff_render::DiffSummary; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::file_search::FileSearchManager; -use crate::history_cell::HistoryCell; -use crate::history_cell::UserHistoryCell; -use crate::model_migration::ModelMigrationOutcome; -use crate::model_migration::migration_copy_for_models; -use crate::model_migration::run_model_migration_prompt; -use crate::pager_overlay::Overlay; -use crate::render::highlight::highlight_bash_to_lines; -use crate::render::renderable::Renderable; -use crate::resume_picker::SessionSelection; -use crate::transcript_copy_action::TranscriptCopyAction; -use crate::transcript_copy_action::TranscriptCopyFeedback; -use crate::transcript_copy_ui::TranscriptCopyUi; -use crate::transcript_multi_click::TranscriptMultiClick; -use crate::transcript_scrollbar::render_transcript_scrollbar_if_active; -use crate::transcript_scrollbar::split_transcript_area; -use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseEvent; -use crate::transcript_scrollbar_ui::TranscriptScrollbarMouseHandling; -use crate::transcript_scrollbar_ui::TranscriptScrollbarUi; -use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; -use crate::transcript_selection::TranscriptSelection; -use crate::transcript_selection::TranscriptSelectionPoint; -use crate::transcript_view_cache::TranscriptViewCache; -use crate::tui; -use crate::tui::TuiEvent; -use crate::tui::scrolling::MouseScrollState; -use crate::tui::scrolling::ScrollConfig; -use crate::tui::scrolling::ScrollConfigOverrides; -use crate::tui::scrolling::ScrollDirection; -use crate::tui::scrolling::ScrollUpdate; -use crate::tui::scrolling::TranscriptScroll; -use crate::update_action::UpdateAction; -use codex_ansi_escape::ansi_escape_line; -use codex_core::AuthManager; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core::config::edit::ConfigEditsBuilder; -#[cfg(target_os = "windows")] -use codex_core::features::Feature; -use codex_core::models_manager::manager::RefreshStrategy; -use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::FinalOutput; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::Op; -use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillErrorInfo; -use codex_core::protocol::TokenUsage; -use codex_core::terminal::terminal_info; -use codex_protocol::ThreadId; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ModelUpgrade; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use color_eyre::eyre::Result; -use color_eyre::eyre::WrapErr; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::MouseButton; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; -use std::collections::BTreeMap; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::thread; -use std::time::Duration; -use tokio::select; -use tokio::sync::mpsc::unbounded_channel; - -#[cfg(not(debug_assertions))] -use crate::history_cell::UpdateAvailableHistoryCell; - -#[derive(Debug, Clone)] -pub struct AppExitInfo { - pub token_usage: TokenUsage, - pub conversation_id: Option, - pub update_action: Option, - pub exit_reason: ExitReason, - /// ANSI-styled transcript lines to print after the TUI exits. - /// - /// These lines are rendered against the same width as the final TUI - /// viewport and include styling (colors, bold, etc.) so that scrollback - /// preserves the visual structure of the on-screen transcript. - pub session_lines: Vec, -} - -#[derive(Debug)] -pub(crate) enum AppRunControl { - Continue, - Exit(ExitReason), -} - -#[derive(Debug, Clone)] -pub enum ExitReason { - UserRequested, - Fatal(String), -} - -impl From for codex_tui::AppExitInfo { - fn from(info: AppExitInfo) -> Self { - let exit_reason = match info.exit_reason { - ExitReason::UserRequested => codex_tui::ExitReason::UserRequested, - ExitReason::Fatal(message) => codex_tui::ExitReason::Fatal(message), - }; - codex_tui::AppExitInfo { - token_usage: info.token_usage, - thread_id: info.conversation_id, - update_action: info.update_action.map(Into::into), - exit_reason, - } - } -} - -fn session_summary( - token_usage: TokenUsage, - conversation_id: Option, -) -> Option { - if token_usage.is_zero() { - return None; - } - - let usage_line = FinalOutput::from(token_usage).to_string(); - let resume_command = - conversation_id.map(|conversation_id| format!("codex resume {conversation_id}")); - Some(SessionSummary { - usage_line, - resume_command, - }) -} - -fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { - response - .skills - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| entry.errors.clone()) - .unwrap_or_default() -} - -fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { - if errors.is_empty() { - return; - } - - let error_count = errors.len(); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_warning_event(format!( - "Skipped loading {error_count} skill(s) due to invalid SKILL.md files." - )), - ))); - - for error in errors { - let path = error.path.display(); - let message = error.message.as_str(); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_warning_event(format!("{path}: {message}")), - ))); - } -} - -fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option) { - let Some(DeprecationNoticeEvent { summary, details }) = notice else { - return; - }; - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_deprecation_notice(summary, details), - ))); -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct SessionSummary { - usage_line: String, - resume_command: Option, -} - -fn should_show_model_migration_prompt( - current_model: &str, - target_model: &str, - seen_migrations: &BTreeMap, - available_models: &[ModelPreset], -) -> bool { - if target_model == current_model { - return false; - } - - if let Some(seen_target) = seen_migrations.get(current_model) - && seen_target == target_model - { - return false; - } - - if available_models - .iter() - .any(|preset| preset.model == current_model && preset.upgrade.is_some()) - { - return true; - } - - if available_models - .iter() - .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model)) - { - return true; - } - - false -} - -fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool { - match migration_config_key { - HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config - .notices - .hide_gpt_5_1_codex_max_migration_prompt - .unwrap_or(false), - HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => { - config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false) - } - _ => false, - } -} - -async fn handle_model_migration_prompt_if_needed( - tui: &mut tui::Tui, - config: &mut Config, - model: &str, - app_event_tx: &AppEventSender, - available_models: Vec, -) -> Option { - let upgrade = available_models - .iter() - .find(|preset| preset.model == model) - .and_then(|preset| preset.upgrade.as_ref()); - - if let Some(ModelUpgrade { - id: target_model, - reasoning_effort_mapping, - migration_config_key, - migration_markdown, - .. - }) = upgrade - { - if migration_prompt_hidden(config, migration_config_key.as_str()) { - return None; - } - - let target_model = target_model.to_string(); - if !should_show_model_migration_prompt( - model, - &target_model, - &config.notices.model_migrations, - &available_models, - ) { - return None; - } - - let current_preset = available_models.iter().find(|preset| preset.model == model); - let target_preset = available_models - .iter() - .find(|preset| preset.model == target_model); - let target_display_name = target_preset - .map(|preset| preset.display_name.clone()) - .unwrap_or_else(|| target_model.clone()); - let heading_label = if target_display_name == model { - target_model.clone() - } else { - target_display_name.clone() - }; - let target_description = target_preset.and_then(|preset| { - if preset.description.is_empty() { - None - } else { - Some(preset.description.clone()) - } - }); - let can_opt_out = current_preset.is_some(); - let prompt_copy = migration_copy_for_models( - model, - &target_model, - heading_label, - target_description, - migration_markdown.clone(), - can_opt_out, - ); - match run_model_migration_prompt(tui, prompt_copy).await { - ModelMigrationOutcome::Accepted => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - config.model = Some(target_model.clone()); - - let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping - && let Some(reasoning_effort) = config.model_reasoning_effort - { - reasoning_effort_mapping - .get(&reasoning_effort) - .cloned() - .or(config.model_reasoning_effort) - } else { - config.model_reasoning_effort - }; - - config.model_reasoning_effort = mapped_effort; - - app_event_tx.send(AppEvent::UpdateModel(target_model.clone())); - app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort)); - app_event_tx.send(AppEvent::PersistModelSelection { - model: target_model.clone(), - effort: mapped_effort, - }); - } - ModelMigrationOutcome::Rejected => { - app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { - from_model: model.to_string(), - to_model: target_model.clone(), - }); - } - ModelMigrationOutcome::Exit => { - return Some(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - exit_reason: ExitReason::UserRequested, - session_lines: Vec::new(), - }); - } - } - } - - None -} - -pub(crate) struct App { - pub(crate) server: Arc, - pub(crate) app_event_tx: AppEventSender, - pub(crate) chat_widget: ChatWidget, - pub(crate) auth_manager: Arc, - /// Config is stored here so we can recreate ChatWidgets as needed. - pub(crate) config: Config, - pub(crate) current_model: String, - pub(crate) active_profile: Option, - - pub(crate) file_search: FileSearchManager, - - pub(crate) transcript_cells: Vec>, - transcript_view_cache: TranscriptViewCache, - - #[allow(dead_code)] - transcript_scroll: TranscriptScroll, - transcript_selection: TranscriptSelection, - transcript_multi_click: TranscriptMultiClick, - transcript_view_top: usize, - transcript_total_lines: usize, - transcript_copy_ui: TranscriptCopyUi, - transcript_copy_action: TranscriptCopyAction, - transcript_scrollbar_ui: TranscriptScrollbarUi, - - // Pager overlay state (Transcript or Static like Diff). - pub(crate) overlay: Option, - /// History cells received while an overlay is active. - /// - /// While in an alt-screen overlay, the normal terminal buffer is not visible. - /// Instead we queue the incoming cells here and, on overlay close, render them at the *current* - /// width and queue them in one batch via `Tui::insert_history_lines`. - /// - /// This matters for correctness if/when scrollback printing is enabled: if we deferred - /// already-rendered `Vec`, we'd bake viewport-width wrapping based on the width at the - /// time the cell arrived (which may differ from the width when the overlay closes). - pub(crate) deferred_history_cells: Vec>, - /// True once at least one history cell has been inserted into terminal scrollback. - /// - /// Used to decide whether to insert an extra blank separator line when flushing deferred cells. - pub(crate) has_emitted_history_lines: bool, - - pub(crate) enhanced_keys_supported: bool, - - /// Controls the animation thread that sends CommitTick events. - pub(crate) commit_anim_running: Arc, - - scroll_config: ScrollConfig, - scroll_state: MouseScrollState, - - // Esc-backtracking state grouped - pub(crate) backtrack: crate::app_backtrack::BacktrackState, - /// When set, the next draw re-renders the transcript into terminal scrollback once. - /// - /// This is used after a confirmed conversation rollback to ensure scrollback reflects the - /// trimmed transcript cells. - pub(crate) backtrack_render_pending: bool, - pub(crate) feedback: codex_feedback::CodexFeedback, - /// Set when the user confirms an update; propagated on exit. - pub(crate) pending_update_action: Option, - - /// Ignore the next ShutdownComplete event when we're intentionally - /// stopping a conversation (e.g., before starting a new one). - suppress_shutdown_complete: bool, - - // One-shot suppression of the next world-writable scan after user confirmation. - skip_world_writable_scan_once: bool, -} -impl App { - async fn shutdown_current_conversation(&mut self) { - if let Some(conversation_id) = self.chat_widget.conversation_id() { - // Clear any in-flight rollback guard when switching conversations. - self.backtrack.pending_rollback = None; - self.suppress_shutdown_complete = true; - self.chat_widget.submit_op(Op::Shutdown); - self.server.remove_thread(&conversation_id).await; - } - } - - #[allow(clippy::too_many_arguments)] - pub async fn run( - tui: &mut tui::Tui, - auth_manager: Arc, - mut config: Config, - active_profile: Option, - initial_prompt: Option, - initial_images: Vec, - session_selection: SessionSelection, - feedback: codex_feedback::CodexFeedback, - is_first_run: bool, - ollama_chat_support_notice: Option, - ) -> Result { - use tokio_stream::StreamExt; - let (app_event_tx, mut app_event_rx) = unbounded_channel(); - let app_event_tx = AppEventSender::new(app_event_tx); - emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); - - let thread_manager = Arc::new(ThreadManager::new( - config.codex_home.clone(), - auth_manager.clone(), - SessionSource::Cli, - )); - let mut model = thread_manager - .get_models_manager() - .get_default_model(&config.model, &config, RefreshStrategy::Offline) - .await; - let available_models = thread_manager - .get_models_manager() - .list_models(&config, RefreshStrategy::Offline) - .await; - let exit_info = handle_model_migration_prompt_if_needed( - tui, - &mut config, - model.as_str(), - &app_event_tx, - available_models, - ) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - if let Some(updated_model) = config.model.clone() { - model = updated_model; - } - - let enhanced_keys_supported = tui.enhanced_keys_supported(); - let mut chat_widget = match session_selection { - SessionSelection::StartFresh | SessionSelection::Exit => { - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), - enhanced_keys_supported, - auth_manager: auth_manager.clone(), - models_manager: thread_manager.get_models_manager(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - }; - ChatWidget::new(init, thread_manager.clone()) - } - SessionSelection::Resume(path) => { - let resumed = thread_manager - .resume_thread_from_rollout(config.clone(), path.clone(), auth_manager.clone()) - .await - .wrap_err_with(|| { - let path_display = path.display(); - format!("Failed to resume session from {path_display}") - })?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), - enhanced_keys_supported, - auth_manager: auth_manager.clone(), - models_manager: thread_manager.get_models_manager(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - }; - ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) - } - SessionSelection::Fork(path) => { - let forked = thread_manager - .fork_thread(usize::MAX, config.clone(), path.clone()) - .await - .wrap_err_with(|| { - let path_display = path.display(); - format!("Failed to fork session from {path_display}") - })?; - let init = crate::chatwidget::ChatWidgetInit { - config: config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: app_event_tx.clone(), - initial_prompt: initial_prompt.clone(), - initial_images: initial_images.clone(), - enhanced_keys_supported, - auth_manager: auth_manager.clone(), - models_manager: thread_manager.get_models_manager(), - feedback: feedback.clone(), - is_first_run, - model: config.model.clone(), - }; - ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) - } - }; - - chat_widget.maybe_prompt_windows_sandbox_enable(); - - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - #[cfg(not(debug_assertions))] - let upgrade_version = crate::updates::get_upgrade_version(&config); - let scroll_config = ScrollConfig::from_terminal( - &terminal_info(), - ScrollConfigOverrides { - events_per_tick: config.tui_scroll_events_per_tick, - wheel_lines_per_tick: config.tui_scroll_wheel_lines, - trackpad_lines_per_tick: config.tui_scroll_trackpad_lines, - trackpad_accel_events: config.tui_scroll_trackpad_accel_events, - trackpad_accel_max: config.tui_scroll_trackpad_accel_max, - mode: Some(config.tui_scroll_mode), - wheel_tick_detect_max_ms: config.tui_scroll_wheel_tick_detect_max_ms, - wheel_like_max_duration_ms: config.tui_scroll_wheel_like_max_duration_ms, - invert_direction: config.tui_scroll_invert, - }, - ); - - let copy_selection_shortcut = crate::transcript_copy_ui::detect_copy_selection_shortcut(); - - let mut app = Self { - server: thread_manager.clone(), - app_event_tx, - chat_widget, - auth_manager: auth_manager.clone(), - config, - current_model: model.clone(), - active_profile, - file_search, - enhanced_keys_supported, - transcript_cells: Vec::new(), - transcript_view_cache: TranscriptViewCache::new(), - transcript_scroll: TranscriptScroll::default(), - transcript_selection: TranscriptSelection::default(), - transcript_multi_click: TranscriptMultiClick::default(), - transcript_view_top: 0, - transcript_total_lines: 0, - transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), - transcript_copy_action: TranscriptCopyAction::default(), - transcript_scrollbar_ui: TranscriptScrollbarUi::default(), - overlay: None, - deferred_history_cells: Vec::new(), - has_emitted_history_lines: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - scroll_config, - scroll_state: MouseScrollState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: feedback.clone(), - pending_update_action: None, - suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, - }; - - // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. - #[cfg(target_os = "windows")] - { - let should_check = codex_core::get_platform_sandbox().is_some() - && matches!( - app.config.sandbox_policy.get(), - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly - ) - && !app - .config - .notices - .hide_world_writable_warning - .unwrap_or(false); - if should_check { - let cwd = app.config.cwd.clone(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - let tx = app.app_event_tx.clone(); - let logs_base_dir = app.config.codex_home.clone(); - let sandbox_policy = app.config.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); - } - } - - #[cfg(not(debug_assertions))] - if let Some(latest_version) = upgrade_version { - let control = app - .handle_event( - tui, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - latest_version, - crate::update_action::get_update_action(), - ))), - ) - .await?; - if let AppRunControl::Exit(exit_reason) = control { - return Ok(AppExitInfo { - token_usage: app.token_usage(), - conversation_id: app.chat_widget.conversation_id(), - update_action: app.pending_update_action, - exit_reason, - session_lines: Vec::new(), - }); - } - } - - let tui_events = tui.event_stream(); - tokio::pin!(tui_events); - - tui.frame_requester().schedule_frame(); - - let exit_reason = loop { - let control = select! { - Some(event) = app_event_rx.recv() => { - app.handle_event(tui, event).await? - } - Some(event) = tui_events.next() => { - app.handle_tui_event(tui, event).await? - } - }; - match control { - AppRunControl::Continue => {} - AppRunControl::Exit(reason) => break reason, - } - }; - let width = tui.terminal.last_known_screen_size.width; - let session_lines = if width == 0 { - Vec::new() - } else { - let transcript = - crate::transcript_render::build_transcript_lines(&app.transcript_cells, width); - let (lines, line_meta) = (transcript.lines, transcript.meta); - let is_user_cell: Vec = app - .transcript_cells - .iter() - .map(|cell| cell.as_any().is::()) - .collect(); - crate::transcript_render::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width) - }; - - tui.terminal.clear()?; - Ok(AppExitInfo { - token_usage: app.token_usage(), - conversation_id: app.chat_widget.conversation_id(), - update_action: app.pending_update_action, - exit_reason, - session_lines, - }) - } - - pub(crate) async fn handle_tui_event( - &mut self, - tui: &mut tui::Tui, - event: TuiEvent, - ) -> Result { - if matches!(&event, TuiEvent::Draw) { - self.handle_scroll_tick(tui); - } - - if self.overlay.is_some() { - let _ = self.handle_backtrack_overlay_event(tui, event).await?; - } else { - match event { - TuiEvent::Key(key_event) => { - self.handle_key_event(tui, key_event).await; - } - TuiEvent::Mouse(mouse_event) => { - self.handle_mouse_event(tui, mouse_event); - } - TuiEvent::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - self.chat_widget.handle_paste(pasted); - } - TuiEvent::Draw => { - if self.backtrack_render_pending { - self.backtrack_render_pending = false; - self.render_transcript_once(tui); - } - self.chat_widget.maybe_post_pending_notification(tui); - if self - .chat_widget - .handle_paste_burst_tick(tui.frame_requester()) - { - return Ok(AppRunControl::Continue); - } - let cells = self.transcript_cells.clone(); - tui.draw(tui.terminal.size()?.height, |frame| { - let chat_height = self.chat_widget.desired_height(frame.area().width); - let chat_top = self.render_transcript_cells(frame, &cells, chat_height); - let chat_area = Rect { - x: frame.area().x, - y: chat_top, - width: frame.area().width, - height: chat_height.min( - frame - .area() - .height - .saturating_sub(chat_top.saturating_sub(frame.area().y)), - ), - }; - self.chat_widget.render(chat_area, frame.buffer); - let chat_bottom = chat_area.y.saturating_add(chat_area.height); - if chat_bottom < frame.area().bottom() { - Clear.render_ref( - Rect { - x: frame.area().x, - y: chat_bottom, - width: frame.area().width, - height: frame.area().bottom().saturating_sub(chat_bottom), - }, - frame.buffer, - ); - } - if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { - frame.set_cursor_position((x, y)); - } - })?; - let transcript_scrolled = - !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); - let selection_active = matches!( - (self.transcript_selection.anchor, self.transcript_selection.head), - (Some(a), Some(b)) if a != b - ); - let scroll_position = if self.transcript_total_lines == 0 { - None - } else { - Some(( - self.transcript_view_top.saturating_add(1), - self.transcript_total_lines, - )) - }; - let copy_selection_key = self.copy_selection_key(); - let copy_feedback = self.transcript_copy_feedback_for_footer(); - self.chat_widget.set_transcript_ui_state( - transcript_scrolled, - selection_active, - scroll_position, - copy_selection_key, - copy_feedback, - ); - } - } - } - Ok(AppRunControl::Continue) - } - - pub(crate) fn render_transcript_cells( - &mut self, - frame: &mut Frame, - cells: &[Arc], - chat_height: u16, - ) -> u16 { - let area = frame.area(); - if area.width == 0 || area.height == 0 { - self.transcript_scroll = TranscriptScroll::default(); - self.transcript_view_top = 0; - self.transcript_total_lines = 0; - return area.bottom().saturating_sub(chat_height); - } - - let chat_height = chat_height.min(area.height); - let max_transcript_height = area.height.saturating_sub(chat_height); - if max_transcript_height == 0 { - self.transcript_scroll = TranscriptScroll::default(); - self.transcript_view_top = 0; - self.transcript_total_lines = 0; - return area.y; - } - - let transcript_full_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: max_transcript_height, - }; - let (transcript_area, _) = split_transcript_area(transcript_full_area); - - self.transcript_view_cache - .ensure_wrapped(cells, transcript_area.width); - let total_lines = self.transcript_view_cache.lines().len(); - if total_lines == 0 { - Clear.render_ref(transcript_full_area, frame.buffer); - self.transcript_scroll = TranscriptScroll::default(); - self.transcript_view_top = 0; - self.transcript_total_lines = 0; - return area.y; - } - - self.transcript_total_lines = total_lines; - let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); - let max_start = total_lines.saturating_sub(max_visible); - - let (scroll_state, top_offset) = { - let line_meta = self.transcript_view_cache.line_meta(); - self.transcript_scroll.resolve_top(line_meta, max_start) - }; - self.transcript_scroll = scroll_state; - self.transcript_view_top = top_offset; - - let transcript_visible_height = max_visible as u16; - let chat_top = if total_lines <= max_transcript_height as usize { - let gap = if transcript_visible_height == 0 { 0 } else { 1 }; - area.y - .saturating_add(transcript_visible_height) - .saturating_add(gap) - } else { - area.bottom().saturating_sub(chat_height) - }; - - let clear_height = chat_top.saturating_sub(area.y); - if clear_height > 0 { - Clear.render_ref( - Rect { - x: area.x, - y: area.y, - width: area.width, - height: clear_height, - }, - frame.buffer, - ); - } - - let transcript_full_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: transcript_visible_height, - }; - let (transcript_area, transcript_scrollbar_area) = - split_transcript_area(transcript_full_area); - - // Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply - // copy already-rendered `Cell`s instead of re-running grapheme segmentation. - self.transcript_view_cache - .set_raster_capacity(max_visible.saturating_mul(4).max(256)); - - for (row_index, line_index) in (top_offset..total_lines).enumerate() { - if row_index >= max_visible { - break; - } - - let y = transcript_area.y + row_index as u16; - let row_area = Rect { - x: transcript_area.x, - y, - width: transcript_area.width, - height: 1, - }; - - self.transcript_view_cache - .render_row_index_into(line_index, row_area, frame.buffer); - } - - self.apply_transcript_selection(transcript_area, frame.buffer); - if let (Some(anchor), Some(head)) = ( - self.transcript_selection.anchor, - self.transcript_selection.head, - ) && anchor != head - { - self.transcript_copy_ui.render_copy_pill( - transcript_area, - frame.buffer, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - self.transcript_view_top, - self.transcript_total_lines, - ); - } else { - self.transcript_copy_ui.clear_affordance(); - } - render_transcript_scrollbar_if_active( - frame.buffer, - transcript_scrollbar_area, - total_lines, - max_visible, - top_offset, - ); - chat_top - } - - /// Handle mouse interaction in the main transcript view. - /// - /// - Mouse wheel movement scrolls the conversation history using stream-based - /// normalization (events-per-line factor, discrete vs. continuous streams), - /// independent of the terminal's own scrollback. - /// - Mouse drags adjust a text selection defined in terms of - /// flattened transcript lines and columns, so the selection is anchored - /// to the underlying content rather than absolute screen rows. - /// - When the user drags to extend a selection while the view is following the bottom - /// and a task is actively running (e.g., streaming a response), the scroll mode is - /// first converted into an anchored position so that ongoing updates no longer move - /// the viewport under the selection. A simple click without a drag does not change - /// scroll behavior. - /// - Mouse events outside the transcript area (e.g. over the composer/footer) must not - /// start or mutate transcript selection state. A left-click outside the transcript - /// clears any existing transcript selection so the user can dismiss the highlight. - fn handle_mouse_event( - &mut self, - tui: &mut tui::Tui, - mouse_event: crossterm::event::MouseEvent, - ) { - use crossterm::event::MouseEventKind; - - if self.overlay.is_some() { - return; - } - - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return; - } - - let chat_height = self.chat_widget.desired_height(width); - if chat_height >= height { - return; - } - - // Only handle events over the transcript area above the composer. - let transcript_height = height.saturating_sub(chat_height); - if transcript_height == 0 { - return; - } - - let transcript_full_area = Rect { - x: 0, - y: 0, - width, - height: transcript_height, - }; - let (transcript_area, transcript_scrollbar_area) = - split_transcript_area(transcript_full_area); - let base_x = transcript_area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); - let max_x = transcript_area.right().saturating_sub(1); - - if matches!( - self.transcript_scrollbar_ui - .handle_mouse_event(TranscriptScrollbarMouseEvent { - tui, - mouse_event, - transcript_area, - scrollbar_area: transcript_scrollbar_area, - transcript_cells: &self.transcript_cells, - transcript_view_cache: &mut self.transcript_view_cache, - transcript_scroll: &mut self.transcript_scroll, - transcript_view_top: &mut self.transcript_view_top, - transcript_total_lines: &mut self.transcript_total_lines, - mouse_scroll_state: &mut self.scroll_state, - }), - TranscriptScrollbarMouseHandling::Handled - ) { - return; - } - - // Treat the transcript as the only interactive region for transcript selection. - // - // This prevents clicks in the composer/footer from starting or extending a transcript - // selection, while still allowing a left-click outside the transcript to clear an - // existing highlight. - if !self.transcript_scrollbar_ui.pointer_capture_active() - && (mouse_event.row < transcript_full_area.y - || mouse_event.row >= transcript_full_area.bottom()) - { - if matches!( - mouse_event.kind, - MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) - ) && (self.transcript_selection.anchor.is_some() - || self.transcript_selection.head.is_some()) - { - self.transcript_selection = TranscriptSelection::default(); - // Mouse events do not inherently trigger a redraw; schedule one so the cleared - // highlight is reflected immediately. - tui.frame_requester().schedule_frame(); - } - return; - } - - let mut clamped_x = mouse_event.column; - let clamped_y = mouse_event.row; - if clamped_x < base_x { - clamped_x = base_x; - } - if clamped_x > max_x { - clamped_x = max_x; - } - - let streaming = self.chat_widget.is_task_running(); - - if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) - && self - .transcript_copy_ui - .hit_test(mouse_event.column, mouse_event.row) - { - if self.transcript_copy_action.copy_and_handle( - tui, - chat_height, - &self.transcript_cells, - self.transcript_selection, - ) { - self.transcript_selection = TranscriptSelection::default(); - } - return; - } - - match mouse_event.kind { - MouseEventKind::ScrollUp => { - let scroll_update = self.mouse_scroll_update(ScrollDirection::Up); - self.apply_scroll_update( - tui, - scroll_update, - transcript_area.height as usize, - transcript_area.width, - true, - ); - } - MouseEventKind::ScrollDown => { - let scroll_update = self.mouse_scroll_update(ScrollDirection::Down); - self.apply_scroll_update( - tui, - scroll_update, - transcript_area.height as usize, - transcript_area.width, - true, - ); - } - MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {} - MouseEventKind::Down(MouseButton::Left) => { - self.transcript_copy_ui.set_dragging(true); - let point = self.transcript_point_from_coordinates( - transcript_area, - base_x, - clamped_x, - clamped_y, - ); - if self.transcript_multi_click.on_mouse_down( - &mut self.transcript_selection, - &self.transcript_cells, - transcript_area.width, - point, - ) { - tui.frame_requester().schedule_frame(); - } - } - MouseEventKind::Drag(MouseButton::Left) => { - let point = self.transcript_point_from_coordinates( - transcript_area, - base_x, - clamped_x, - clamped_y, - ); - let outcome = crate::transcript_selection::on_mouse_drag( - &mut self.transcript_selection, - &self.transcript_scroll, - point, - streaming, - ); - self.transcript_multi_click - .on_mouse_drag(&self.transcript_selection, point); - if outcome.lock_scroll { - self.lock_transcript_scroll_to_current_view( - transcript_area.height as usize, - transcript_area.width, - ); - } - if outcome.changed { - tui.frame_requester().schedule_frame(); - } - } - MouseEventKind::Up(MouseButton::Left) => { - self.transcript_copy_ui.set_dragging(false); - let selection_changed = - crate::transcript_selection::on_mouse_up(&mut self.transcript_selection); - let has_active_selection = self.transcript_selection.anchor.is_some() - && self.transcript_selection.head.is_some(); - if selection_changed || has_active_selection { - tui.frame_requester().schedule_frame(); - } - } - _ => {} - } - } - - /// Convert a single mouse scroll event (direction-only) into a normalized scroll update. - /// - /// This delegates to [`MouseScrollState::on_scroll_event`] using the current [`ScrollConfig`]. - /// The returned [`ScrollUpdate`] is intentionally split into: - /// - /// - `lines`: a *delta* in visual lines to apply immediately to the transcript viewport. - /// - Sign convention matches [`ScrollDirection`] (`Up` is negative; `Down` is positive). - /// - May be 0 in trackpad-like mode while sub-line fractions are still accumulating. - /// - `next_tick_in`: an optional delay after which we should trigger a follow-up tick. - /// This is required because stream closure is defined by a *time gap* rather than an - /// explicit "gesture end" event. See [`App::apply_scroll_update`] and - /// [`App::handle_scroll_tick`]. - /// - /// In TUI2, that follow-up tick is driven via `TuiEvent::Draw`: we schedule a frame, and on - /// the next draw we call [`MouseScrollState::on_tick`] to close idle streams and flush any - /// newly-reached whole lines. This prevents perceived "stop lag" where accumulated scroll only - /// applies once the next user input arrives. - fn mouse_scroll_update(&mut self, direction: ScrollDirection) -> ScrollUpdate { - self.scroll_state - .on_scroll_event(direction, self.scroll_config) - } - - /// Apply a [`ScrollUpdate`] to the transcript viewport and schedule any needed follow-up tick. - /// - /// `update.lines` is applied immediately via [`App::scroll_transcript`]. - /// - /// If `update.next_tick_in` is `Some`, we schedule a future frame so `TuiEvent::Draw` can call - /// [`App::handle_scroll_tick`] and close the stream after it goes idle and/or cadence-flush - /// pending whole lines. - /// - /// `schedule_frame` is forwarded to [`App::scroll_transcript`] and controls whether scrolling - /// should request an additional draw. Pass `false` when applying scroll during a - /// `TuiEvent::Draw` tick to avoid redundant frames. - fn apply_scroll_update( - &mut self, - tui: &mut tui::Tui, - update: ScrollUpdate, - visible_lines: usize, - width: u16, - schedule_frame: bool, - ) { - if update.lines != 0 { - self.scroll_transcript(tui, update.lines, visible_lines, width, schedule_frame); - } - if let Some(delay) = update.next_tick_in { - tui.frame_requester().schedule_frame_in(delay); - } - } - - /// Drive stream closure and cadence-based flushing for mouse scrolling. - /// - /// This is called on every `TuiEvent::Draw` before rendering. If a scroll stream is active, it - /// may: - /// - /// - Close the stream once it has been idle for longer than the stream-gap threshold. - /// - Flush whole-line deltas on the redraw cadence for trackpad-like streams, even if no new - /// events arrive. - /// - /// The resulting update is applied with `schedule_frame = false` because we are already in a - /// draw tick. - fn handle_scroll_tick(&mut self, tui: &mut tui::Tui) { - let Some((visible_lines, width)) = self.transcript_scroll_dimensions(tui) else { - return; - }; - let update = self.scroll_state.on_tick(); - self.apply_scroll_update(tui, update, visible_lines, width, false); - } - - /// Compute the transcript viewport dimensions used for scrolling. - /// - /// Mouse scrolling is applied in terms of "visible transcript lines": the terminal height - /// minus the chat composer height. We compute this from the last known terminal size to avoid - /// querying the terminal during non-draw events. - /// - /// Returns `(visible_lines, width)` or `None` when the terminal is not yet sized or the chat - /// area consumes the full height. - fn transcript_scroll_dimensions(&self, tui: &tui::Tui) -> Option<(usize, u16)> { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return None; - } - - let chat_height = self.chat_widget.desired_height(width); - if chat_height >= height { - return None; - } - - let transcript_height = height.saturating_sub(chat_height); - if transcript_height == 0 { - return None; - } - - let transcript_full_area = Rect { - x: 0, - y: 0, - width, - height: transcript_height, - }; - let (transcript_area, _) = split_transcript_area(transcript_full_area); - - Some((transcript_height as usize, transcript_area.width)) - } - - /// Scroll the transcript by a number of visual lines. - /// - /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in - /// the main view. Scroll state is expressed in terms of transcript cells and their - /// internal line indices, so scrolling refers to logical conversation content and - /// remains stable even as wrapping or streaming causes visual reflows. - /// - /// `schedule_frame` controls whether to request an extra draw; pass `false` when applying - /// scroll during a `TuiEvent::Draw` tick to avoid redundant frames. - fn scroll_transcript( - &mut self, - tui: &mut tui::Tui, - delta_lines: i32, - visible_lines: usize, - width: u16, - schedule_frame: bool, - ) { - if visible_lines == 0 { - return; - } - - self.transcript_view_cache - .ensure_wrapped(&self.transcript_cells, width); - let line_meta = self.transcript_view_cache.line_meta(); - self.transcript_scroll = - self.transcript_scroll - .scrolled_by(delta_lines, line_meta, visible_lines); - - if schedule_frame { - // Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps. - tui.frame_requester().schedule_frame(); - } - } - - /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. - /// - /// When the user begins a mouse selection while new output is streaming in, the view - /// should stop auto-following the latest line so the selection stays on the intended - /// content. This helper inspects the flattened transcript at the given width, derives - /// a concrete position corresponding to the current top row, and switches into a scroll - /// mode that keeps that position stable until the user scrolls again. - fn lock_transcript_scroll_to_current_view(&mut self, visible_lines: usize, width: u16) { - if self.transcript_cells.is_empty() || visible_lines == 0 || width == 0 { - return; - } - - self.transcript_view_cache - .ensure_wrapped(&self.transcript_cells, width); - let lines = self.transcript_view_cache.lines(); - let line_meta = self.transcript_view_cache.line_meta(); - if lines.is_empty() || line_meta.is_empty() { - return; - } - - let total_lines = lines.len(); - let max_visible = std::cmp::min(visible_lines, total_lines); - if max_visible == 0 { - return; - } - - let max_start = total_lines.saturating_sub(max_visible); - let top_offset = match self.transcript_scroll { - TranscriptScroll::ToBottom => max_start, - TranscriptScroll::Scrolled { .. } - | TranscriptScroll::ScrolledSpacerBeforeCell { .. } => { - // Already anchored; nothing to lock. - return; - } - }; - - if let Some(scroll_state) = TranscriptScroll::anchor_for(line_meta, top_offset) { - self.transcript_scroll = scroll_state; - } - } - - /// Apply the current transcript selection to the given buffer. - /// - /// The selection is defined in terms of flattened wrapped transcript line - /// indices and columns. This method maps those content-relative endpoints - /// into the currently visible viewport based on `transcript_view_top` and - /// `transcript_total_lines`, so the highlight moves with the content as the - /// user scrolls. - fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { - let (anchor, head) = match ( - self.transcript_selection.anchor, - self.transcript_selection.head, - ) { - (Some(a), Some(h)) => (a, h), - _ => return, - }; - - if self.transcript_total_lines == 0 { - return; - } - - let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS); - let max_x = area.right().saturating_sub(1); - - let (start, end) = crate::transcript_selection::ordered_endpoints(anchor, head); - - let visible_start = self.transcript_view_top; - let visible_end = self - .transcript_view_top - .saturating_add(area.height as usize) - .min(self.transcript_total_lines); - - for (row_index, line_index) in (visible_start..visible_end).enumerate() { - if line_index < start.line_index || line_index > end.line_index { - continue; - } - - let y = area.y + row_index as u16; - - let mut first_text_x = None; - let mut last_text_x = None; - for x in base_x..=max_x { - let cell = &buf[(x, y)]; - if cell.symbol() != " " { - if first_text_x.is_none() { - first_text_x = Some(x); - } - last_text_x = Some(x); - } - } - - let (text_start, text_end) = match (first_text_x, last_text_x) { - // Treat indentation spaces as part of the selectable region by - // starting from the first content column to the right of the - // transcript gutter, but still clamp to the last non-space - // glyph so trailing padding is not included. - (Some(_), Some(e)) => (base_x, e), - _ => continue, - }; - - let line_start_col = if line_index == start.line_index { - start.column - } else { - 0 - }; - let line_end_col = if line_index == end.line_index { - end.column - } else { - max_x.saturating_sub(base_x) - }; - - let row_sel_start = base_x.saturating_add(line_start_col); - let row_sel_end = base_x.saturating_add(line_end_col).min(max_x); - - if row_sel_start > row_sel_end { - continue; - } - - let from_x = row_sel_start.max(text_start); - let to_x = row_sel_end.min(text_end); - - if from_x > to_x { - continue; - } - - for x in from_x..=to_x { - let cell = &mut buf[(x, y)]; - let style = cell.style(); - cell.set_style(style.add_modifier(ratatui::style::Modifier::REVERSED)); - } - } - } - - fn transcript_copy_feedback_for_footer(&mut self) -> Option { - self.transcript_copy_action.footer_feedback() - } - - fn copy_selection_key(&self) -> crate::key_hint::KeyBinding { - self.transcript_copy_ui.key_binding() - } - - /// Map a mouse position in the transcript area to a content-relative - /// selection point, if there is transcript content to select. - fn transcript_point_from_coordinates( - &self, - transcript_area: Rect, - base_x: u16, - x: u16, - y: u16, - ) -> Option { - if self.transcript_total_lines == 0 { - return None; - } - - let mut row_index = y.saturating_sub(transcript_area.y); - if row_index >= transcript_area.height { - if transcript_area.height == 0 { - return None; - } - row_index = transcript_area.height.saturating_sub(1); - } - - let max_line = self.transcript_total_lines.saturating_sub(1); - let line_index = self - .transcript_view_top - .saturating_add(usize::from(row_index)) - .min(max_line); - let column = x.saturating_sub(base_x); - - Some(TranscriptSelectionPoint { line_index, column }) - } - - async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { - match event { - AppEvent::NewSession => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - self.shutdown_current_conversation().await; - if let Err(err) = self.server.remove_and_close_all_threads().await { - tracing::warn!(error = %err, "failed to close all threads"); - } - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); - if let Some(summary) = summary { - let mut lines: Vec> = vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - tui.frame_requester().schedule_frame(); - } - AppEvent::OpenResumePicker => { - match crate::resume_picker::run_resume_picker( - tui, - &self.config.codex_home, - &self.config.model_provider_id, - false, - ) - .await? - { - SessionSelection::Resume(path) => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - match self - .server - .resume_thread_from_rollout( - self.config.clone(), - path.clone(), - self.auth_manager.clone(), - ) - .await - { - Ok(resumed) => { - self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new_from_existing( - init, - resumed.thread, - resumed.session_configured, - ); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to resume session from {path_display}: {err}" - )); - } - } - } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::Fork(_) => {} - } - - // Leaving alt-screen may blank the inline viewport; force a redraw either way. - tui.frame_requester().schedule_frame(); - } - AppEvent::ForkCurrentSession => { - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.conversation_id(), - ); - if let Some(path) = self.chat_widget.rollout_path() { - match self - .server - .fork_thread(usize::MAX, self.config.clone(), path.clone()) - .await - { - Ok(forked) => { - self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: Some(self.current_model.clone()), - }; - self.chat_widget = ChatWidget::new_from_existing( - init, - forked.thread, - forked.session_configured, - ); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - } - Err(err) => { - let path_display = path.display(); - self.chat_widget.add_error_message(format!( - "Failed to fork current session from {path_display}: {err}" - )); - } - } - } else { - self.chat_widget - .add_error_message("Current session is not ready to fork yet.".to_string()); - } - - tui.frame_requester().schedule_frame(); - } - AppEvent::InsertHistoryCell(cell) => { - let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { - transcript.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_cells.push(cell.clone()); - if self.overlay.is_some() { - self.deferred_history_cells.push(cell); - } else { - tui.frame_requester().schedule_frame(); - } - } - AppEvent::StartCommitAnimation => { - if self - .commit_anim_running - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok() - { - let tx = self.app_event_tx.clone(); - let running = self.commit_anim_running.clone(); - thread::spawn(move || { - while running.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(50)); - tx.send(AppEvent::CommitTick); - } - }); - } - } - AppEvent::StopCommitAnimation => { - self.commit_anim_running.store(false, Ordering::Release); - } - AppEvent::CommitTick => { - self.chat_widget.on_commit_tick(); - } - AppEvent::CodexEvent(event) => { - if self.suppress_shutdown_complete - && matches!(event.msg, EventMsg::ShutdownComplete) - { - self.suppress_shutdown_complete = false; - return Ok(AppRunControl::Continue); - } - if let EventMsg::ListSkillsResponse(response) = &event.msg { - let cwd = self.chat_widget.config_ref().cwd.clone(); - let errors = errors_for_cwd(&cwd, response); - emit_skill_load_warnings(&self.app_event_tx, &errors); - } - self.handle_backtrack_event(&event.msg); - self.chat_widget.handle_codex_event(event); - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } - } - AppEvent::Exit(mode) => match mode { - ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), - ExitMode::Immediate => { - return Ok(AppRunControl::Exit(ExitReason::UserRequested)); - } - }, - AppEvent::FatalExitRequest(message) => { - return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); - } - AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), - AppEvent::DiffResult(text) => { - // Clear the in-progress state in the bottom pane - self.chat_widget.on_diff_complete(); - // Enter alternate screen using TUI helper and build pager lines - let _ = tui.enter_alt_screen(); - let pager_lines: Vec> = if text.trim().is_empty() { - vec!["No changes detected.".italic().into()] - } else { - text.lines().map(ansi_escape_line).collect() - }; - self.overlay = Some(Overlay::new_static_with_lines( - pager_lines, - "D I F F".to_string(), - )); - tui.frame_requester().schedule_frame(); - } - AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } - } - AppEvent::FileSearchResult { query, matches } => { - self.chat_widget.apply_file_search_result(query, matches); - } - AppEvent::RateLimitSnapshotFetched(snapshot) => { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } - AppEvent::ConnectorsLoaded(result) => { - self.chat_widget.on_connectors_loaded(result); - } - AppEvent::InsertComposerText(text) => { - self.chat_widget.insert_composer_text(&text); - } - AppEvent::UpdateReasoningEffort(effort) => { - self.on_update_reasoning_effort(effort); - } - AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(&model); - self.current_model = model; - } - AppEvent::OpenReasoningPopup { model } => { - self.chat_widget.open_reasoning_popup(model); - } - AppEvent::OpenAllModelsPopup { models } => { - self.chat_widget.open_all_models_popup(models); - } - AppEvent::OpenFullAccessConfirmation { preset } => { - self.chat_widget.open_full_access_confirmation(preset); - } - AppEvent::OpenWorldWritableWarningConfirmation { - preset, - sample_paths, - extra_count, - failed_scan, - } => { - self.chat_widget.open_world_writable_warning_confirmation( - preset, - sample_paths, - extra_count, - failed_scan, - ); - } - AppEvent::OpenFeedbackNote { - category, - include_logs, - } => { - self.chat_widget.open_feedback_note(category, include_logs); - } - AppEvent::OpenFeedbackConsent { category } => { - self.chat_widget.open_feedback_consent(category); - } - AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { - self.chat_widget.open_windows_sandbox_enable_prompt(preset); - } - AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { - self.chat_widget.clear_windows_sandbox_setup_status(); - self.chat_widget - .open_windows_sandbox_fallback_prompt(preset, reason); - } - AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { - #[cfg(target_os = "windows")] - { - let policy = preset.sandbox.clone(); - let policy_cwd = self.config.cwd.clone(); - let command_cwd = policy_cwd.clone(); - let env_map: std::collections::HashMap = - std::env::vars().collect(); - let codex_home = self.config.codex_home.clone(); - let tx = self.app_event_tx.clone(); - - // If the elevated setup already ran on this machine, don't prompt for - // elevation again - just flip the config to use the elevated path. - if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) - { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset, - mode: WindowsSandboxEnableMode::Elevated, - }); - return Ok(AppRunControl::Continue); - } - - self.chat_widget.show_windows_sandbox_setup_status(); - tokio::task::spawn_blocking(move || { - let result = codex_core::windows_sandbox::run_elevated_setup( - &policy, - policy_cwd.as_path(), - command_cwd.as_path(), - &env_map, - codex_home.as_path(), - ); - let event = match result { - Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }, - Err(err) => { - tracing::error!( - error = %err, - "failed to run elevated Windows sandbox setup" - ); - AppEvent::OpenWindowsSandboxFallbackPrompt { - preset, - reason: WindowsSandboxFallbackReason::ElevationFailed, - } - } - }; - tx.send(event); - }); - } - #[cfg(not(target_os = "windows"))] - { - let _ = preset; - } - } - AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { - #[cfg(target_os = "windows")] - { - self.chat_widget.clear_windows_sandbox_setup_status(); - let profile = self.active_profile.as_deref(); - let feature_key = Feature::WindowsSandbox.key(); - let elevated_key = Feature::WindowsSandboxElevated.key(); - let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); - match ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(profile) - .set_feature_enabled(feature_key, true) - .set_feature_enabled(elevated_key, elevated_enabled) - .apply() - .await - { - Ok(()) => { - self.config.set_windows_sandbox_globally(true); - self.config - .set_windows_elevated_sandbox_globally(elevated_enabled); - self.chat_widget - .set_feature_enabled(Feature::WindowsSandbox, true); - self.chat_widget.set_feature_enabled( - Feature::WindowsSandboxElevated, - elevated_enabled, - ); - self.chat_widget.clear_forced_auto_mode_downgrade(); - if let Some((sample_paths, extra_count, failed_scan)) = - self.chat_widget.world_writable_warning_details() - { - self.app_event_tx.send( - AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset.clone()), - sample_paths, - extra_count, - failed_scan, - }, - ); - } else { - self.app_event_tx.send(AppEvent::CodexOp( - Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(preset.approval), - sandbox_policy: Some(preset.sandbox.clone()), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - }, - )); - self.app_event_tx - .send(AppEvent::UpdateAskForApprovalPolicy(preset.approval)); - self.app_event_tx - .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); - self.chat_widget.add_info_message( - match mode { - WindowsSandboxEnableMode::Elevated => { - "Enabled elevated agent sandbox.".to_string() - } - WindowsSandboxEnableMode::Legacy => { - "Enabled non-elevated agent sandbox.".to_string() - } - }, - None, - ); - } - } - Err(err) => { - tracing::error!( - error = %err, - "failed to enable Windows sandbox feature" - ); - self.chat_widget.add_error_message(format!( - "Failed to enable the Windows sandbox feature: {err}" - )); - } - } - } - #[cfg(not(target_os = "windows"))] - { - let _ = (preset, mode); - } - } - AppEvent::PersistModelSelection { model, effort } => { - let profile = self.active_profile.as_deref(); - match ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(profile) - .set_model(Some(model.as_str()), effort) - .apply() - .await - { - Ok(()) => { - let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort) { - message.push(' '); - message.push_str(label); - } - if let Some(profile) = profile { - message.push_str(" for "); - message.push_str(profile); - message.push_str(" profile"); - } - self.chat_widget.add_info_message(message, None); - } - Err(err) => { - tracing::error!( - error = %err, - "failed to persist model selection" - ); - if let Some(profile) = profile { - self.chat_widget.add_error_message(format!( - "Failed to save model for profile `{profile}`: {err}" - )); - } else { - self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); - } - } - } - } - AppEvent::UpdateAskForApprovalPolicy(policy) => { - self.chat_widget.set_approval_policy(policy); - } - AppEvent::UpdateSandboxPolicy(policy) => { - #[cfg(target_os = "windows")] - let policy_is_workspace_write_or_ro = matches!( - &policy, - codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_core::protocol::SandboxPolicy::ReadOnly - ); - - if let Err(err) = self.config.sandbox_policy.set(policy.clone()) { - tracing::warn!(%err, "failed to set sandbox policy on app config"); - self.chat_widget - .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(AppRunControl::Continue); - } - #[cfg(target_os = "windows")] - if !matches!(&policy, codex_core::protocol::SandboxPolicy::ReadOnly) - || codex_core::get_platform_sandbox().is_some() - { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - if let Err(err) = self.chat_widget.set_sandbox_policy(policy) { - tracing::warn!(%err, "failed to set sandbox policy on chat config"); - self.chat_widget - .add_error_message(format!("Failed to set sandbox policy: {err}")); - return Ok(AppRunControl::Continue); - } - - // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. - #[cfg(target_os = "windows")] - { - // One-shot suppression if the user just confirmed continue. - if self.skip_world_writable_scan_once { - self.skip_world_writable_scan_once = false; - return Ok(AppRunControl::Continue); - } - - let should_check = codex_core::get_platform_sandbox().is_some() - && policy_is_workspace_write_or_ro - && !self.chat_widget.world_writable_warning_hidden(); - if should_check { - let cwd = self.config.cwd.clone(); - let env_map: std::collections::HashMap = - std::env::vars().collect(); - let tx = self.app_event_tx.clone(); - let logs_base_dir = self.config.codex_home.clone(); - let sandbox_policy = self.config.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan( - cwd, - env_map, - logs_base_dir, - sandbox_policy, - tx, - ); - } - } - } - AppEvent::SkipNextWorldWritableScan => { - self.skip_world_writable_scan_once = true; - } - AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { - self.chat_widget.set_full_access_warning_acknowledged(ack); - } - AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => { - self.chat_widget - .set_world_writable_warning_acknowledged(ack); - } - AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => { - self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); - } - AppEvent::PersistFullAccessWarningAcknowledged => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .set_hide_full_access_warning(true) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist full access warning acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save full access confirmation preference: {err}" - )); - } - } - AppEvent::PersistWorldWritableWarningAcknowledged => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .set_hide_world_writable_warning(true) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist world-writable warning acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save Agent mode warning preference: {err}" - )); - } - } - AppEvent::PersistRateLimitSwitchPromptHidden => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .set_hide_rate_limit_model_nudge(true) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist rate limit switch prompt preference" - ); - self.chat_widget.add_error_message(format!( - "Failed to save rate limit reminder preference: {err}" - )); - } - } - AppEvent::PersistModelMigrationPromptAcknowledged { - from_model, - to_model, - } => { - if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) - .record_model_migration_seen(from_model.as_str(), to_model.as_str()) - .apply() - .await - { - tracing::error!( - error = %err, - "failed to persist model migration prompt acknowledgement" - ); - self.chat_widget.add_error_message(format!( - "Failed to save model migration prompt preference: {err}" - )); - } - } - AppEvent::OpenApprovalsPopup => { - self.chat_widget.open_approvals_popup(); - } - AppEvent::OpenReviewBranchPicker(cwd) => { - self.chat_widget.show_review_branch_picker(&cwd).await; - } - AppEvent::OpenReviewCommitPicker(cwd) => { - self.chat_widget.show_review_commit_picker(&cwd).await; - } - AppEvent::OpenReviewCustomPrompt => { - self.chat_widget.show_review_custom_prompt(); - } - AppEvent::FullScreenApprovalRequest(request) => match request { - ApprovalRequest::ApplyPatch { cwd, changes, .. } => { - let _ = tui.enter_alt_screen(); - let diff_summary = DiffSummary::new(changes, cwd); - self.overlay = Some(Overlay::new_static_with_renderables( - vec![diff_summary.into()], - "P A T C H".to_string(), - )); - } - ApprovalRequest::Exec { command, .. } => { - let _ = tui.enter_alt_screen(); - let full_cmd = strip_bash_lc_and_escape(&command); - let full_cmd_lines = highlight_bash_to_lines(&full_cmd); - self.overlay = Some(Overlay::new_static_with_lines( - full_cmd_lines, - "E X E C".to_string(), - )); - } - ApprovalRequest::McpElicitation { - server_name, - message, - .. - } => { - let _ = tui.enter_alt_screen(); - let paragraph = Paragraph::new(vec![ - Line::from(vec!["Server: ".into(), server_name.bold()]), - Line::from(""), - Line::from(message), - ]) - .wrap(Wrap { trim: false }); - self.overlay = Some(Overlay::new_static_with_renderables( - vec![Box::new(paragraph)], - "E L I C I T A T I O N".to_string(), - )); - } - }, - } - Ok(AppRunControl::Continue) - } - - fn reasoning_label(reasoning_effort: Option) -> &'static str { - match reasoning_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", - } - } - - fn reasoning_label_for( - model: &str, - reasoning_effort: Option, - ) -> Option<&'static str> { - (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) - } - - pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { - self.chat_widget.token_usage() - } - - fn on_update_reasoning_effort(&mut self, effort: Option) { - self.chat_widget.set_reasoning_effort(effort); - self.config.model_reasoning_effort = effort; - } - - async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Char('t'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - // Enter alternate screen and set viewport to full size. - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); - } - // Esc primes/advances backtracking only in normal (not working) mode - // with the composer focused and empty. In any other state, forward - // Esc so the active UI (e.g. status indicator, modals, popups) - // handles it. - KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - if self.chat_widget.is_normal_backtrack_mode() - && self.chat_widget.composer_is_empty() - { - self.handle_backtrack_esc_key(tui); - } else { - self.chat_widget.handle_key_event(key_event); - } - } - KeyEvent { - code: KeyCode::Char(ch), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } if self.transcript_copy_ui.is_copy_key(ch, modifiers) => { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return; - } - - let chat_height = self.chat_widget.desired_height(width); - if self.transcript_copy_action.copy_and_handle( - tui, - chat_height, - &self.transcript_cells, - self.transcript_selection, - ) { - self.transcript_selection = TranscriptSelection::default(); - } - } - KeyEvent { - code: KeyCode::PageUp, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width > 0 && height > 0 { - let chat_height = self.chat_widget.desired_height(width); - if chat_height < height { - let transcript_height = height.saturating_sub(chat_height); - if transcript_height > 0 { - let delta = -i32::from(transcript_height); - self.scroll_transcript( - tui, - delta, - usize::from(transcript_height), - width, - true, - ); - } - } - } - } - KeyEvent { - code: KeyCode::PageDown, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width > 0 && height > 0 { - let chat_height = self.chat_widget.desired_height(width); - if chat_height < height { - let transcript_height = height.saturating_sub(chat_height); - if transcript_height > 0 { - let delta = i32::from(transcript_height); - self.scroll_transcript( - tui, - delta, - usize::from(transcript_height), - width, - true, - ); - } - } - } - } - KeyEvent { - code: KeyCode::Home, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - if !self.transcript_cells.is_empty() { - self.transcript_scroll = TranscriptScroll::Scrolled { - cell_index: 0, - line_in_cell: 0, - }; - tui.frame_requester().schedule_frame(); - } - } - KeyEvent { - code: KeyCode::End, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.transcript_scroll = TranscriptScroll::ToBottom; - tui.frame_requester().schedule_frame(); - } - // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. - KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - } if self.backtrack.primed - && self.backtrack.nth_user_message != usize::MAX - && self.chat_widget.composer_is_empty() => - { - if let Some(selection) = self.confirm_backtrack_from_main() { - self.apply_backtrack_selection(tui, selection); - } - } - KeyEvent { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - // Any non-Esc key press should cancel a primed backtrack. - // This avoids stale "Esc-primed" state after the user starts typing - // (even if they later backspace to empty). - if key_event.code != KeyCode::Esc && self.backtrack.primed { - self.reset_backtrack_state(); - } - self.chat_widget.handle_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; - } - - #[cfg(target_os = "windows")] - fn spawn_world_writable_scan( - cwd: PathBuf, - env_map: std::collections::HashMap, - logs_base_dir: PathBuf, - sandbox_policy: codex_core::protocol::SandboxPolicy, - tx: AppEventSender, - ) { - tokio::task::spawn_blocking(move || { - let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( - &logs_base_dir, - &cwd, - &env_map, - &sandbox_policy, - Some(logs_base_dir.as_path()), - ); - if result.is_err() { - // Scan failed: warn without examples. - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: None, - sample_paths: Vec::new(), - extra_count: 0usize, - failed_scan: true, - }); - } - }); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_backtrack::BacktrackState; - use crate::app_backtrack::user_count; - use crate::chatwidget::tests::make_chatwidget_manual_with_sender; - use crate::file_search::FileSearchManager; - use crate::history_cell::AgentMessageCell; - use crate::history_cell::HistoryCell; - use crate::history_cell::UserHistoryCell; - use crate::history_cell::new_session_info; - use crate::transcript_copy_ui::CopySelectionShortcut; - use crate::tui::scrolling::TranscriptLineMeta; - use codex_core::AuthManager; - use codex_core::CodexAuth; - use codex_core::ThreadManager; - use codex_core::config::ConfigBuilder; - use codex_core::protocol::AskForApproval; - use codex_core::protocol::Event; - use codex_core::protocol::EventMsg; - use codex_core::protocol::SandboxPolicy; - use codex_core::protocol::SessionConfiguredEvent; - use codex_protocol::ThreadId; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - use ratatui::prelude::Line; - use std::path::PathBuf; - use std::sync::Arc; - use std::sync::atomic::AtomicBool; - use tempfile::tempdir; - - async fn make_test_app() -> App { - let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; - let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); - let server = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - )); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - - App { - server, - app_event_tx, - chat_widget, - auth_manager, - config, - current_model, - active_profile: None, - file_search, - transcript_cells: Vec::new(), - transcript_view_cache: TranscriptViewCache::new(), - transcript_scroll: TranscriptScroll::default(), - transcript_selection: TranscriptSelection::default(), - transcript_multi_click: TranscriptMultiClick::default(), - transcript_view_top: 0, - transcript_total_lines: 0, - transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( - CopySelectionShortcut::CtrlShiftC, - ), - transcript_copy_action: TranscriptCopyAction::default(), - transcript_scrollbar_ui: TranscriptScrollbarUi::default(), - overlay: None, - deferred_history_cells: Vec::new(), - has_emitted_history_lines: false, - enhanced_keys_supported: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - scroll_config: ScrollConfig::default(), - scroll_state: MouseScrollState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), - pending_update_action: None, - suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, - } - } - - async fn make_test_app_with_channels() -> ( - App, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, - ) { - let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; - let config = chat_widget.config_ref().clone(); - let current_model = "gpt-5.2-codex".to_string(); - let server = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("Test API Key"), - config.model_provider.clone(), - )); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - - ( - App { - server, - app_event_tx, - chat_widget, - auth_manager, - config, - current_model, - active_profile: None, - file_search, - transcript_cells: Vec::new(), - transcript_view_cache: TranscriptViewCache::new(), - transcript_scroll: TranscriptScroll::default(), - transcript_selection: TranscriptSelection::default(), - transcript_multi_click: TranscriptMultiClick::default(), - transcript_view_top: 0, - transcript_total_lines: 0, - transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( - CopySelectionShortcut::CtrlShiftC, - ), - transcript_copy_action: TranscriptCopyAction::default(), - transcript_scrollbar_ui: TranscriptScrollbarUi::default(), - overlay: None, - deferred_history_cells: Vec::new(), - has_emitted_history_lines: false, - enhanced_keys_supported: false, - commit_anim_running: Arc::new(AtomicBool::new(false)), - scroll_config: ScrollConfig::default(), - scroll_state: MouseScrollState::default(), - backtrack: BacktrackState::default(), - backtrack_render_pending: false, - feedback: codex_feedback::CodexFeedback::new(), - pending_update_action: None, - suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, - }, - rx, - op_rx, - ) - } - - fn all_model_presets() -> Vec { - codex_core::models_manager::model_presets::all_model_presets().clone() - } - - fn model_migration_copy_to_plain_text( - copy: &crate::model_migration::ModelMigrationCopy, - ) -> String { - if let Some(markdown) = copy.markdown.as_ref() { - return markdown.clone(); - } - let mut s = String::new(); - for span in ©.heading { - s.push_str(&span.content); - } - s.push('\n'); - s.push('\n'); - for line in ©.content { - for span in &line.spans { - s.push_str(&span.content); - } - s.push('\n'); - } - s - } - - #[tokio::test] - async fn model_migration_prompt_only_shows_for_deprecated_models() { - let seen = BTreeMap::new(); - assert!(should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5-codex-mini", - "gpt-5.1-codex-mini", - &seen, - &all_model_presets() - )); - assert!(should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex-max", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1-codex", - "gpt-5.1-codex", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn model_migration_prompt_shows_for_hidden_model() { - let codex_home = tempdir().expect("temp codex home"); - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("config"); - - let available_models = all_model_presets(); - let current = available_models - .iter() - .find(|preset| preset.model == "gpt-5.1-codex") - .cloned() - .expect("gpt-5.1-codex preset present"); - assert!( - !current.show_in_picker, - "expected gpt-5.1-codex to be hidden from picker for this test" - ); - - let upgrade = current.upgrade.as_ref().expect("upgrade configured"); - assert!( - should_show_model_migration_prompt( - ¤t.model, - &upgrade.id, - &config.notices.model_migrations, - &available_models, - ), - "expected migration prompt to be eligible for hidden model" - ); - - let target = available_models - .iter() - .find(|preset| preset.model == upgrade.id) - .cloned() - .expect("upgrade target present"); - let target_description = - (!target.description.is_empty()).then(|| target.description.clone()); - let can_opt_out = true; - let copy = migration_copy_for_models( - ¤t.model, - &upgrade.id, - target.display_name, - target_description, - upgrade.migration_markdown.clone(), - can_opt_out, - ); - - assert_snapshot!( - "model_migration_prompt_shows_for_hidden_model", - model_migration_copy_to_plain_text(©) - ); - } - - #[tokio::test] - async fn transcript_selection_copy_includes_offscreen_lines() { - let mut app = make_test_app().await; - app.transcript_cells = vec![Arc::new(AgentMessageCell::new( - vec![ - Line::from("one"), - Line::from("two"), - Line::from("three"), - Line::from("four"), - ], - true, - ))]; - - app.transcript_view_top = 2; - app.transcript_selection.anchor = Some(TranscriptSelectionPoint { - line_index: 0, - column: 0, - }); - app.transcript_selection.head = Some(TranscriptSelectionPoint { - line_index: 3, - column: u16::MAX, - }); - - let text = crate::transcript_copy::selection_to_copy_text_for_cells( - &app.transcript_cells, - app.transcript_selection, - 40, - ) - .expect("expected text"); - assert_eq!(text, "one\ntwo\nthree\nfour"); - } - - #[tokio::test] - async fn model_migration_prompt_respects_hide_flag_and_self_target() { - let mut seen = BTreeMap::new(); - seen.insert("gpt-5".to_string(), "gpt-5.1".to_string()); - assert!(!should_show_model_migration_prompt( - "gpt-5", - "gpt-5.1", - &seen, - &all_model_presets() - )); - assert!(!should_show_model_migration_prompt( - "gpt-5.1", - "gpt-5.1", - &seen, - &all_model_presets() - )); - } - - #[tokio::test] - async fn update_reasoning_effort_updates_config() { - let mut app = make_test_app().await; - app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium); - app.chat_widget - .set_reasoning_effort(Some(ReasoningEffortConfig::Medium)); - - app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); - - assert_eq!( - app.config.model_reasoning_effort, - Some(ReasoningEffortConfig::High) - ); - assert_eq!( - app.chat_widget.config_ref().model_reasoning_effort, - Some(ReasoningEffortConfig::High) - ); - } - - #[tokio::test] - async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { - let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - - let user_cell = |text: &str| -> Arc { - Arc::new(UserHistoryCell { - message: text.to_string(), - }) as Arc - }; - let agent_cell = |text: &str| -> Arc { - Arc::new(AgentMessageCell::new( - vec![Line::from(text.to_string())], - true, - )) as Arc - }; - - let make_header = |is_first| { - let event = SessionConfiguredEvent { - session_id: ThreadId::new(), - forked_from_id: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: PathBuf::new(), - }; - Arc::new(new_session_info( - app.chat_widget.config_ref(), - app.current_model.as_str(), - event, - is_first, - )) as Arc - }; - - // Simulate a transcript with duplicated history (e.g., from prior backtracks) - // and an edited turn appended after a session header boundary. - app.transcript_cells = vec![ - make_header(true), - user_cell("first question"), - agent_cell("answer first"), - user_cell("follow-up"), - agent_cell("answer follow-up"), - make_header(false), - user_cell("first question"), - agent_cell("answer first"), - user_cell("follow-up (edited)"), - agent_cell("answer edited"), - ]; - - assert_eq!(user_count(&app.transcript_cells), 2); - - let base_id = ThreadId::new(); - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: base_id, - forked_from_id: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: PathBuf::new(), - }), - }); - - app.backtrack.base_id = Some(base_id); - app.backtrack.primed = true; - app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); - - let selection = app - .confirm_backtrack_from_main() - .expect("backtrack selection"); - assert_eq!(selection.nth_user_message, 1); - assert_eq!(selection.prefill, "follow-up (edited)"); - - app.apply_backtrack_rollback(selection); - - let mut rollback_turns = None; - while let Ok(op) = op_rx.try_recv() { - if let Op::ThreadRollback { num_turns } = op { - rollback_turns = Some(num_turns); - } - } - - assert_eq!(rollback_turns, Some(1)); - } - - #[tokio::test] - async fn transcript_selection_moves_with_scroll() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_total_lines = 3; - - let area = Rect { - x: 0, - y: 0, - width: 10, - height: 2, - }; - - // Anchor selection to logical line 1, columns 2..4. - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 4, - }), - }; - - // First render: top of view is line 0, so line 1 maps to the second row. - app.transcript_view_top = 0; - let mut buf = Buffer::empty(area); - for x in 2..area.width { - buf[(x, 0)].set_symbol("A"); - buf[(x, 1)].set_symbol("B"); - } - - app.apply_transcript_selection(area, &mut buf); - - // No selection should be applied to the first row when the view is anchored at the top. - for x in 0..area.width { - let cell = &buf[(x, 0)]; - assert!(cell.style().add_modifier.is_empty()); - } - - // After scrolling down by one line, the same logical line should now be - // rendered on the first row, and the highlight should move with it. - app.transcript_view_top = 1; - let mut buf_scrolled = Buffer::empty(area); - for x in 2..area.width { - buf_scrolled[(x, 0)].set_symbol("B"); - buf_scrolled[(x, 1)].set_symbol("C"); - } - - app.apply_transcript_selection(area, &mut buf_scrolled); - - // After scrolling, the selection should now be applied on the first row rather than the - // second. - for x in 0..area.width { - let cell = &buf_scrolled[(x, 1)]; - assert!(cell.style().add_modifier.is_empty()); - } - } - - #[tokio::test] - async fn transcript_selection_renders_copy_affordance() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_total_lines = 3; - app.transcript_view_top = 0; - - let area = Rect { - x: 0, - y: 0, - width: 60, - height: 3, - }; - - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 6, - }), - }; - - let mut buf = Buffer::empty(area); - for y in 0..area.height { - for x in 2..area.width.saturating_sub(1) { - buf[(x, y)].set_symbol("X"); - } - } - - app.apply_transcript_selection(area, &mut buf); - let anchor = app.transcript_selection.anchor.expect("anchor"); - let head = app.transcript_selection.head.expect("head"); - app.transcript_copy_ui.render_copy_pill( - area, - &mut buf, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - app.transcript_view_top, - app.transcript_total_lines, - ); - - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - - assert!(s.contains("copy")); - assert!(s.contains("ctrl + shift + c")); - assert!(app.transcript_copy_ui.hit_test(10, 2)); - } - - #[tokio::test] - async fn transcript_selection_renders_ctrl_y_copy_affordance_in_vscode_mode() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_copy_ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY); - app.transcript_total_lines = 3; - app.transcript_view_top = 0; - - let area = Rect { - x: 0, - y: 0, - width: 60, - height: 3, - }; - - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 6, - }), - }; - - let mut buf = Buffer::empty(area); - for y in 0..area.height { - for x in 2..area.width.saturating_sub(1) { - buf[(x, y)].set_symbol("X"); - } - } - - app.apply_transcript_selection(area, &mut buf); - let anchor = app.transcript_selection.anchor.expect("anchor"); - let head = app.transcript_selection.head.expect("head"); - app.transcript_copy_ui.render_copy_pill( - area, - &mut buf, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - app.transcript_view_top, - app.transcript_total_lines, - ); - - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - - assert!(s.contains("copy")); - assert!(s.contains("ctrl + y")); - assert!(!s.contains("ctrl + shift + c")); - assert!(app.transcript_copy_ui.hit_test(10, 2)); - } - - #[tokio::test] - async fn transcript_selection_hides_copy_affordance_while_dragging() { - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - - let mut app = make_test_app().await; - app.transcript_total_lines = 3; - app.transcript_view_top = 0; - app.transcript_copy_ui.set_dragging(true); - - let area = Rect { - x: 0, - y: 0, - width: 60, - height: 3, - }; - - app.transcript_selection = TranscriptSelection { - anchor: Some(TranscriptSelectionPoint { - line_index: 1, - column: 2, - }), - head: Some(TranscriptSelectionPoint { - line_index: 1, - column: 6, - }), - }; - - let mut buf = Buffer::empty(area); - for y in 0..area.height { - for x in 2..area.width.saturating_sub(1) { - buf[(x, y)].set_symbol("X"); - } - } - - let anchor = app.transcript_selection.anchor.expect("anchor"); - let head = app.transcript_selection.head.expect("head"); - app.transcript_copy_ui.render_copy_pill( - area, - &mut buf, - (anchor.line_index, anchor.column), - (head.line_index, head.column), - app.transcript_view_top, - app.transcript_total_lines, - ); - - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - - assert!(!s.contains("copy")); - assert!(!app.transcript_copy_ui.hit_test(10, 2)); - } - - #[tokio::test] - async fn new_session_requests_shutdown_for_previous_conversation() { - let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; - - let conversation_id = ThreadId::new(); - let event = SessionConfiguredEvent { - session_id: conversation_id, - forked_from_id: None, - model: "gpt-test".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - rollout_path: PathBuf::new(), - }; - - app.chat_widget.handle_codex_event(Event { - id: String::new(), - msg: EventMsg::SessionConfigured(event), - }); - - while app_event_rx.try_recv().is_ok() {} - while op_rx.try_recv().is_ok() {} - - app.shutdown_current_conversation().await; - - match op_rx.try_recv() { - Ok(Op::Shutdown) => {} - Ok(other) => panic!("expected Op::Shutdown, got {other:?}"), - Err(_) => panic!("expected shutdown op to be sent"), - } - } - - #[tokio::test] - async fn session_summary_skip_zero_usage() { - assert!(session_summary(TokenUsage::default(), None).is_none()); - } - - #[tokio::test] - async fn render_lines_to_ansi_pads_user_rows_to_full_width() { - let line: Line<'static> = Line::from("hi"); - let lines = vec![line]; - let line_meta = vec![TranscriptLineMeta::CellLine { - cell_index: 0, - line_in_cell: 0, - }]; - let is_user_cell = vec![true]; - let width: u16 = 10; - - let rendered = crate::transcript_render::render_lines_to_ansi( - &lines, - &line_meta, - &is_user_cell, - width, - ); - assert_eq!(rendered.len(), 1); - assert!(rendered[0].contains("hi")); - } - - #[tokio::test] - async fn session_summary_includes_resume_hint() { - let usage = TokenUsage { - input_tokens: 10, - output_tokens: 2, - total_tokens: 12, - ..Default::default() - }; - let conversation = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - - let summary = session_summary(usage, Some(conversation)).expect("summary"); - assert_eq!( - summary.usage_line, - "Token usage: total=12 input=10 output=2" - ); - assert_eq!( - summary.resume_command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) - ); - } -} diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs deleted file mode 100644 index a86ed6dea6..0000000000 --- a/codex-rs/tui2/src/app_backtrack.rs +++ /dev/null @@ -1,665 +0,0 @@ -//! Backtracking and transcript overlay event routing. -//! -//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also -//! mediates a key rendering boundary for the transcript overlay. -//! -//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing -//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to -//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI -//! state diverging from the agent if a rollback fails or targets a different thread. -//! -//! Backtrack operates as a small state machine: -//! - The first `Esc` in the main view "primes" the feature and captures a base conversation id. -//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message. -//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. -//! - Only after receiving `EventMsg::ThreadRolledBack` do we trim local transcript state and -//! schedule a one-time scrollback refresh. -//! -//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live -//! tail derived from the current in-flight `ChatWidget.active_cell`. -//! -//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by -//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into -//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects -//! both committed history and in-flight activity without changing flush or coalescing behavior. - -use std::any::TypeId; -use std::sync::Arc; - -use crate::app::App; -use crate::history_cell::SessionInfoCell; -use crate::history_cell::UserHistoryCell; -use crate::pager_overlay::Overlay; -use crate::tui; -use crate::tui::TuiEvent; -use codex_core::protocol::CodexErrorInfo; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use codex_protocol::ThreadId; -use color_eyre::eyre::Result; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; - -/// Aggregates all backtrack-related state used by the App. -#[derive(Default)] -pub(crate) struct BacktrackState { - /// True when Esc has primed backtrack mode in the main view. - pub(crate) primed: bool, - /// Session id of the base conversation to rollback. - /// - /// If the current conversation changes, backtrack selections become invalid and must be - /// ignored. - pub(crate) base_id: Option, - /// Index of the currently highlighted user message. - /// - /// This is an index into the filtered "user messages since the last session start" view, - /// not an index into `transcript_cells`. `usize::MAX` indicates "no selection". - pub(crate) nth_user_message: usize, - /// True when the transcript overlay is showing a backtrack preview. - pub(crate) overlay_preview_active: bool, - /// Pending rollback request awaiting confirmation from core. - /// - /// This acts as a guardrail: once we request a rollback, we block additional backtrack - /// submissions until core responds with either a success or failure event. - pub(crate) pending_rollback: Option, -} - -/// A user-visible backtrack choice that can be confirmed into a rollback request. -#[derive(Debug, Clone)] -pub(crate) struct BacktrackSelection { - /// The selected user message, counted from the most recent session start. - /// - /// This value is used both to compute the rollback depth and to trim the local transcript - /// after core confirms the rollback. - pub(crate) nth_user_message: usize, - /// Composer prefill derived from the selected user message. - /// - /// This is applied immediately on selection confirmation; if the rollback fails, the prefill - /// remains as a convenience so the user can retry or edit. - pub(crate) prefill: String, -} - -/// An in-flight rollback requested from core. -/// -/// We keep enough information to apply the corresponding local trim only if the response targets -/// the same active conversation we issued the request for. -#[derive(Debug, Clone)] -pub(crate) struct PendingBacktrackRollback { - pub(crate) selection: BacktrackSelection, - pub(crate) thread_id: Option, -} - -impl App { - /// Route overlay events while the transcript overlay is active. - /// - /// If backtrack preview is active, Esc / Left steps selection, Right steps forward, Enter - /// confirms. Otherwise, Esc begins preview mode and all other events are forwarded to the - /// overlay. - pub(crate) async fn handle_backtrack_overlay_event( - &mut self, - tui: &mut tui::Tui, - event: TuiEvent, - ) -> Result { - if self.backtrack.overlay_preview_active { - match event { - TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack_forward(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - }) => { - self.overlay_confirm_backtrack(tui); - Ok(true) - } - // Catchall: forward any other events to the overlay widget. - _ => { - self.overlay_forward_event(tui, event)?; - Ok(true) - } - } - } else if let TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) = event - { - // First Esc in transcript overlay: begin backtrack preview at latest user message. - self.begin_overlay_backtrack_preview(tui); - Ok(true) - } else { - // Not in backtrack mode: forward events to the overlay widget. - self.overlay_forward_event(tui, event)?; - Ok(true) - } - } - - /// Handle global Esc presses for backtracking when no overlay is present. - pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { - if !self.chat_widget.composer_is_empty() { - return; - } - - if !self.backtrack.primed { - self.prime_backtrack(); - } else if self.overlay.is_none() { - self.open_backtrack_preview(tui); - } else if self.backtrack.overlay_preview_active { - self.step_backtrack_and_highlight(tui); - } - } - - /// Stage a backtrack and request thread history from the agent. - /// - /// We send the rollback request immediately, but we only mutate the transcript after core - /// confirms success so the UI cannot get ahead of the actual thread state. - /// - /// The composer prefill is applied immediately as a UX convenience; it does not imply that - /// core has accepted the rollback. - pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) { - let user_total = user_count(&self.transcript_cells); - if user_total == 0 { - return; - } - - if self.backtrack.pending_rollback.is_some() { - self.chat_widget - .add_error_message("Backtrack rollback already in progress.".to_string()); - return; - } - - let num_turns = user_total.saturating_sub(selection.nth_user_message); - let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX); - if num_turns == 0 { - return; - } - - let prefill = selection.prefill.clone(); - self.backtrack.pending_rollback = Some(PendingBacktrackRollback { - selection, - thread_id: self.chat_widget.conversation_id(), - }); - self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - if !prefill.is_empty() { - self.chat_widget.set_composer_text(prefill); - } - } - - /// Open transcript overlay (enters alternate screen and shows full transcript). - pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { - let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); - tui.frame_requester().schedule_frame(); - } - - /// Close transcript overlay and restore normal UI. - /// - /// Any history emitted while the overlay was open is flushed to the normal-buffer queue here. - /// - /// Importantly, we defer *cells* (not rendered lines) so we can render them against the current - /// width on close and avoid baking width-derived wrapping based on an earlier viewport size. - /// (This matters if/when scrollback printing is enabled; `Tui::insert_history_lines` currently - /// queues lines without printing them during the main draw loop.) - pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { - let _ = tui.leave_alt_screen(); - let was_backtrack = self.backtrack.overlay_preview_active; - if !self.deferred_history_cells.is_empty() { - let cells = std::mem::take(&mut self.deferred_history_cells); - let width = tui.terminal.last_known_screen_size.width; - let mut lines: Vec> = Vec::new(); - for cell in cells { - let mut display = cell.display_lines(width); - if display.is_empty() { - continue; - } - - // 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, ratatui::text::Line::from("")); - } else { - self.has_emitted_history_lines = true; - } - } - - lines.extend(display); - } - if !lines.is_empty() { - tui.insert_history_lines(lines); - } - } - self.overlay = None; - self.backtrack.overlay_preview_active = false; - if was_backtrack { - // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q'). - self.reset_backtrack_state(); - } - } - - /// Re-render the full transcript into the terminal scrollback in one call. - /// Useful when switching sessions to ensure prior history remains visible. - pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { - if !self.transcript_cells.is_empty() { - let width = tui.terminal.last_known_screen_size.width; - for cell in &self.transcript_cells { - tui.insert_history_lines(cell.display_lines(width)); - } - } - } - - /// Initialize backtrack state and show composer hint. - fn prime_backtrack(&mut self) { - self.backtrack.primed = true; - self.backtrack.nth_user_message = usize::MAX; - self.backtrack.base_id = self.chat_widget.conversation_id(); - self.chat_widget.show_esc_backtrack_hint(); - } - - /// Open overlay and begin backtrack preview flow (first step + highlight). - fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) { - self.open_transcript_overlay(tui); - self.backtrack.overlay_preview_active = true; - // Composer is hidden by overlay; clear its hint. - self.chat_widget.clear_esc_backtrack_hint(); - self.step_backtrack_and_highlight(tui); - } - - /// When overlay is already open, begin preview mode and select latest user message. - fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) { - self.backtrack.primed = true; - self.backtrack.base_id = self.chat_widget.conversation_id(); - self.backtrack.overlay_preview_active = true; - let count = user_count(&self.transcript_cells); - if let Some(last) = count.checked_sub(1) { - self.apply_backtrack_selection_internal(last); - } - tui.frame_requester().schedule_frame(); - } - - /// Step selection to the next older user message and update overlay. - fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let count = user_count(&self.transcript_cells); - if count == 0 { - return; - } - - let last_index = count.saturating_sub(1); - let next_selection = if self.backtrack.nth_user_message == usize::MAX { - last_index - } else if self.backtrack.nth_user_message == 0 { - 0 - } else { - self.backtrack - .nth_user_message - .saturating_sub(1) - .min(last_index) - }; - - self.apply_backtrack_selection_internal(next_selection); - tui.frame_requester().schedule_frame(); - } - - /// Step selection to the next newer user message and update overlay. - fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) { - let count = user_count(&self.transcript_cells); - if count == 0 { - return; - } - - let last_index = count.saturating_sub(1); - let next_selection = if self.backtrack.nth_user_message == usize::MAX { - last_index - } else { - self.backtrack - .nth_user_message - .saturating_add(1) - .min(last_index) - }; - - self.apply_backtrack_selection_internal(next_selection); - tui.frame_requester().schedule_frame(); - } - - /// Apply a computed backtrack selection to the overlay and internal counter. - fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { - if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { - self.backtrack.nth_user_message = nth_user_message; - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.set_highlight_cell(Some(cell_idx)); - } - } else { - self.backtrack.nth_user_message = usize::MAX; - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.set_highlight_cell(None); - } - } - } - - /// Forward any event to the overlay and close it if done. - fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - // Transcript overlay draws are special: include a live, in-flight tail so the - // overlay matches the main viewport while the active cell is still streaming. - // This path also drives tail animations and closes the overlay immediately - // once it reports completion. - if let TuiEvent::Draw = &event - && let Some(Overlay::Transcript(t)) = &mut self.overlay - { - let active_key = self.chat_widget.active_cell_transcript_key(); - let chat_widget = &self.chat_widget; - tui.draw(u16::MAX, |frame| { - let width = frame.area().width.max(1); - t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_lines(w) - }); - t.render(frame.area(), frame.buffer); - })?; - let close_overlay = t.is_done(); - if !close_overlay - && active_key.is_some_and(|key| key.animation_tick.is_some()) - && t.is_scrolled_to_bottom() - { - tui.frame_requester() - .schedule_frame_in(std::time::Duration::from_millis(50)); - } - if close_overlay { - self.close_transcript_overlay(tui); - tui.frame_requester().schedule_frame(); - } - return Ok(()); - } - - if let Some(overlay) = &mut self.overlay { - overlay.handle_event(tui, event)?; - if overlay.is_done() { - self.close_transcript_overlay(tui); - tui.frame_requester().schedule_frame(); - } - } - Ok(()) - } - - /// Handle Enter in overlay backtrack preview: confirm selection and reset state. - fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) { - let nth_user_message = self.backtrack.nth_user_message; - let selection = self.backtrack_selection(nth_user_message); - self.close_transcript_overlay(tui); - if let Some(selection) = selection { - self.apply_backtrack_rollback(selection); - tui.frame_requester().schedule_frame(); - } - } - - /// Handle Esc in overlay backtrack preview: step selection if armed, else forward. - fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if self.backtrack.base_id.is_some() { - self.step_backtrack_and_highlight(tui); - } else { - self.overlay_forward_event(tui, event)?; - } - Ok(()) - } - - /// Handle Right in overlay backtrack preview: step selection forward if armed, else forward. - fn overlay_step_backtrack_forward( - &mut self, - tui: &mut tui::Tui, - event: TuiEvent, - ) -> Result<()> { - if self.backtrack.base_id.is_some() { - self.step_forward_backtrack_and_highlight(tui); - } else { - self.overlay_forward_event(tui, event)?; - } - Ok(()) - } - - /// Confirm a primed backtrack from the main view (no overlay visible). - /// Computes the prefill from the selected user message for rollback. - pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option { - let selection = self.backtrack_selection(self.backtrack.nth_user_message); - self.reset_backtrack_state(); - selection - } - - /// Clear all backtrack-related state and composer hints. - pub(crate) fn reset_backtrack_state(&mut self) { - self.backtrack.primed = false; - self.backtrack.base_id = None; - self.backtrack.nth_user_message = usize::MAX; - // In case a hint is somehow still visible (e.g., race with overlay open/close). - self.chat_widget.clear_esc_backtrack_hint(); - } - - pub(crate) fn apply_backtrack_selection( - &mut self, - tui: &mut tui::Tui, - selection: BacktrackSelection, - ) { - self.apply_backtrack_rollback(selection); - tui.frame_requester().schedule_frame(); - } - - pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { - match event { - EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), - EventMsg::Error(ErrorEvent { - codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), - .. - }) => { - // Core rejected the rollback; clear the guard so the user can retry. - self.backtrack.pending_rollback = None; - } - _ => {} - } - } - - /// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh. - /// - /// We ignore events that do not correspond to the currently active conversation to avoid - /// applying stale updates after a session switch. - fn finish_pending_backtrack(&mut self) { - let Some(pending) = self.backtrack.pending_rollback.take() else { - return; - }; - if pending.thread_id != self.chat_widget.conversation_id() { - // Ignore rollbacks targeting a prior thread. - return; - } - self.trim_transcript_for_backtrack(pending.selection.nth_user_message); - self.backtrack_render_pending = true; - } - fn backtrack_selection(&self, nth_user_message: usize) -> Option { - let base_id = self.backtrack.base_id?; - if self.chat_widget.conversation_id() != Some(base_id) { - return None; - } - - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); - - Some(BacktrackSelection { - nth_user_message, - prefill, - }) - } - - /// Trim `transcript_cells` to preserve only content before the selected user message. - fn trim_transcript_for_backtrack(&mut self, nth_user_message: usize) { - trim_transcript_cells_to_nth_user(&mut self.transcript_cells, nth_user_message); - } -} - -fn trim_transcript_cells_to_nth_user( - transcript_cells: &mut Vec>, - nth_user_message: usize, -) { - if nth_user_message == usize::MAX { - return; - } - - if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) { - transcript_cells.truncate(cut_idx); - } -} - -pub(crate) fn user_count(cells: &[Arc]) -> usize { - user_positions_iter(cells).count() -} - -fn nth_user_position( - cells: &[Arc], - nth: usize, -) -> Option { - user_positions_iter(cells) - .enumerate() - .find_map(|(i, idx)| (i == nth).then_some(idx)) -} - -fn user_positions_iter( - cells: &[Arc], -) -> impl Iterator + '_ { - let session_start_type = TypeId::of::(); - let user_type = TypeId::of::(); - let type_of = |cell: &Arc| cell.as_any().type_id(); - - let start = cells - .iter() - .rposition(|cell| type_of(cell) == session_start_type) - .map_or(0, |idx| idx + 1); - - cells - .iter() - .enumerate() - .skip(start) - .filter_map(move |(idx, cell)| (type_of(cell) == user_type).then_some(idx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::history_cell::AgentMessageCell; - use crate::history_cell::HistoryCell; - use ratatui::prelude::Line; - use std::sync::Arc; - - #[test] - fn trim_transcript_for_first_user_drops_user_and_newer_cells() { - let mut cells: Vec> = vec![ - Arc::new(UserHistoryCell { - message: "first user".to_string(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) - as Arc, - ]; - trim_transcript_cells_to_nth_user(&mut cells, 0); - - assert!(cells.is_empty()); - } - - #[test] - fn trim_transcript_preserves_cells_before_selected_user() { - let mut cells: Vec> = vec![ - Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) - as Arc, - Arc::new(UserHistoryCell { - message: "first".to_string(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) - as Arc, - ]; - trim_transcript_cells_to_nth_user(&mut cells, 0); - - assert_eq!(cells.len(), 1); - let agent = cells[0] - .as_any() - .downcast_ref::() - .expect("agent cell"); - let agent_lines = agent.display_lines(u16::MAX); - assert_eq!(agent_lines.len(), 1); - let intro_text: String = agent_lines[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - assert_eq!(intro_text, "• intro"); - } - - #[test] - fn trim_transcript_for_later_user_keeps_prior_history() { - let mut cells: Vec> = vec![ - Arc::new(AgentMessageCell::new(vec![Line::from("intro")], true)) - as Arc, - Arc::new(UserHistoryCell { - message: "first".to_string(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) - as Arc, - Arc::new(UserHistoryCell { - message: "second".to_string(), - }) as Arc, - Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) - as Arc, - ]; - trim_transcript_cells_to_nth_user(&mut cells, 1); - - assert_eq!(cells.len(), 3); - let agent_intro = cells[0] - .as_any() - .downcast_ref::() - .expect("intro agent"); - let intro_lines = agent_intro.display_lines(u16::MAX); - let intro_text: String = intro_lines[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - assert_eq!(intro_text, "• intro"); - - let user_first = cells[1] - .as_any() - .downcast_ref::() - .expect("first user"); - assert_eq!(user_first.message, "first"); - - let agent_between = cells[2] - .as_any() - .downcast_ref::() - .expect("between agent"); - let between_lines = agent_between.display_lines(u16::MAX); - let between_text: String = between_lines[0] - .spans - .iter() - .map(|span| span.content.as_ref()) - .collect(); - assert_eq!(between_text, " between"); - } -} diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs deleted file mode 100644 index bef0ec2168..0000000000 --- a/codex-rs/tui2/src/app_event.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Application-level events used to coordinate UI actions. -//! -//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop. -//! Widgets emit events to request actions that must be handled at the app layer (like opening -//! pickers, persisting configuration, or shutting down the agent), without needing direct access to -//! `App` internals. -//! -//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first -//! quits without reaching into the app loop or coupling to shutdown/exit sequencing. - -use std::path::PathBuf; - -use codex_chatgpt::connectors::ConnectorInfo; -use codex_common::approval_presets::ApprovalPreset; -use codex_core::protocol::Event; -use codex_core::protocol::RateLimitSnapshot; -use codex_file_search::FileMatch; -use codex_protocol::openai_models::ModelPreset; - -use crate::bottom_pane::ApprovalRequest; -use crate::history_cell::HistoryCell; - -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_protocol::openai_models::ReasoningEffort; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) enum WindowsSandboxEnableMode { - Elevated, - Legacy, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) enum WindowsSandboxFallbackReason { - ElevationFailed, -} - -#[derive(Debug, Clone)] -pub(crate) struct ConnectorsSnapshot { - pub(crate) connectors: Vec, -} - -#[allow(clippy::large_enum_variant)] -#[derive(Debug)] -pub(crate) enum AppEvent { - CodexEvent(Event), - - /// Start a new session. - NewSession, - - /// Open the resume picker inside the running TUI session. - OpenResumePicker, - - /// Fork the current session into a new thread. - ForkCurrentSession, - - /// Request to exit the application. - /// - /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the - /// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort - /// escape hatch that skips shutdown and may drop in-flight work (e.g., - /// background tasks, rollout flush, or child process cleanup). - Exit(ExitMode), - - /// Request to exit the application due to a fatal error. - FatalExitRequest(String), - - /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids - /// bubbling channels through layers of widgets. - CodexOp(codex_core::protocol::Op), - - /// Kick off an asynchronous file search for the given query (text after - /// the `@`). Previous searches may be cancelled by the app layer so there - /// is at most one in-flight search. - StartFileSearch(String), - - /// Result of a completed asynchronous file search. The `query` echoes the - /// original search term so the UI can decide whether the results are - /// still relevant. - FileSearchResult { - query: String, - matches: Vec, - }, - - /// Result of refreshing rate limits - RateLimitSnapshotFetched(RateLimitSnapshot), - - /// Result of prefetching connectors. - ConnectorsLoaded(Result), - - /// Insert text into the chat composer. - InsertComposerText(String), - - /// Result of computing a `/diff` command. - DiffResult(String), - - InsertHistoryCell(Box), - - StartCommitAnimation, - StopCommitAnimation, - CommitTick, - - /// Update the current reasoning effort in the running app and widget. - UpdateReasoningEffort(Option), - - /// Update the current model slug in the running app and widget. - UpdateModel(String), - - /// Persist the selected model and reasoning effort to the appropriate config. - PersistModelSelection { - model: String, - effort: Option, - }, - - /// Open the reasoning selection popup after picking a model. - OpenReasoningPopup { - model: ModelPreset, - }, - - /// Open the full model picker (non-auto models). - OpenAllModelsPopup { - models: Vec, - }, - - /// Open the confirmation prompt before enabling full access mode. - OpenFullAccessConfirmation { - preset: ApprovalPreset, - }, - - /// Open the Windows world-writable directories warning. - /// If `preset` is `Some`, the confirmation will apply the provided - /// approval/sandbox configuration on Continue; if `None`, it performs no - /// policy change and only acknowledges/dismisses the warning. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - OpenWorldWritableWarningConfirmation { - preset: Option, - /// Up to 3 sample world-writable directories to display in the warning. - sample_paths: Vec, - /// If there are more than `sample_paths`, this carries the remaining count. - extra_count: usize, - /// True when the scan failed (e.g. ACL query error) and protections could not be verified. - failed_scan: bool, - }, - - /// Prompt to enable the Windows sandbox feature before using Agent mode. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - OpenWindowsSandboxEnablePrompt { - preset: ApprovalPreset, - }, - - /// Open the Windows sandbox fallback prompt after declining or failing elevation. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - OpenWindowsSandboxFallbackPrompt { - preset: ApprovalPreset, - reason: WindowsSandboxFallbackReason, - }, - - /// Begin the elevated Windows sandbox setup flow. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - BeginWindowsSandboxElevatedSetup { - preset: ApprovalPreset, - }, - - /// Enable the Windows sandbox feature and switch to Agent mode. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - EnableWindowsSandboxForAgentMode { - preset: ApprovalPreset, - mode: WindowsSandboxEnableMode, - }, - - /// Update the current approval policy in the running app and widget. - UpdateAskForApprovalPolicy(AskForApproval), - - /// Update the current sandbox policy in the running app and widget. - UpdateSandboxPolicy(SandboxPolicy), - - /// Update whether the full access warning prompt has been acknowledged. - UpdateFullAccessWarningAcknowledged(bool), - - /// Update whether the world-writable directories warning has been acknowledged. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - UpdateWorldWritableWarningAcknowledged(bool), - - /// Update whether the rate limit switch prompt has been acknowledged for the session. - UpdateRateLimitSwitchPromptHidden(bool), - - /// Persist the acknowledgement flag for the full access warning prompt. - PersistFullAccessWarningAcknowledged, - - /// Persist the acknowledgement flag for the world-writable directories warning. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - PersistWorldWritableWarningAcknowledged, - - /// Persist the acknowledgement flag for the rate limit switch prompt. - PersistRateLimitSwitchPromptHidden, - - /// Persist the acknowledgement flag for the model migration prompt. - PersistModelMigrationPromptAcknowledged { - from_model: String, - to_model: String, - }, - - /// Skip the next world-writable scan (one-shot) after a user-confirmed continue. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - SkipNextWorldWritableScan, - - /// Re-open the approval presets popup. - OpenApprovalsPopup, - - /// Open the branch picker option from the review popup. - OpenReviewBranchPicker(PathBuf), - - /// Open the commit picker option from the review popup. - OpenReviewCommitPicker(PathBuf), - - /// Open the custom prompt option from the review popup. - OpenReviewCustomPrompt, - - /// Open the approval popup. - FullScreenApprovalRequest(ApprovalRequest), - - /// Open the feedback note entry overlay after the user selects a category. - OpenFeedbackNote { - category: FeedbackCategory, - include_logs: bool, - }, - - /// Open the upload consent popup for feedback after selecting a category. - OpenFeedbackConsent { - category: FeedbackCategory, - }, -} - -/// The exit strategy requested by the UI layer. -/// -/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only -/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has -/// already completed (or is being bypassed) and the UI loop should terminate right away. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ExitMode { - /// Shutdown core and exit after completion. - ShutdownFirst, - /// Exit the UI loop immediately without waiting for shutdown. - /// - /// This skips `Op::Shutdown`, so any in-flight work may be dropped and - /// cleanup that normally runs before `ShutdownComplete` can be missed. - Immediate, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum FeedbackCategory { - BadResult, - GoodResult, - Bug, - Other, -} diff --git a/codex-rs/tui2/src/app_event_sender.rs b/codex-rs/tui2/src/app_event_sender.rs deleted file mode 100644 index c1427b3ff0..0000000000 --- a/codex-rs/tui2/src/app_event_sender.rs +++ /dev/null @@ -1,28 +0,0 @@ -use tokio::sync::mpsc::UnboundedSender; - -use crate::app_event::AppEvent; -use crate::session_log; - -#[derive(Clone, Debug)] -pub(crate) struct AppEventSender { - pub app_event_tx: UnboundedSender, -} - -impl AppEventSender { - pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { - Self { app_event_tx } - } - - /// Send an event to the app event channel. If it fails, we swallow the - /// error and log it. - pub(crate) fn send(&self, event: AppEvent) { - // Record inbound events for high-fidelity session replay. - // Avoid double-logging Ops; those are logged at the point of submission. - if !matches!(event, AppEvent::CodexOp(_)) { - session_log::log_inbound_app_event(&event); - } - if let Err(e) = self.app_event_tx.send(event) { - tracing::error!("failed to send event: {e}"); - } - } -} diff --git a/codex-rs/tui2/src/ascii_animation.rs b/codex-rs/tui2/src/ascii_animation.rs deleted file mode 100644 index b2d9fc1d19..0000000000 --- a/codex-rs/tui2/src/ascii_animation.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::convert::TryFrom; -use std::time::Duration; -use std::time::Instant; - -use rand::Rng as _; - -use crate::frames::ALL_VARIANTS; -use crate::frames::FRAME_TICK_DEFAULT; -use crate::tui::FrameRequester; - -/// Drives ASCII art animations shared across popups and onboarding widgets. -pub(crate) struct AsciiAnimation { - request_frame: FrameRequester, - variants: &'static [&'static [&'static str]], - variant_idx: usize, - frame_tick: Duration, - start: Instant, -} - -impl AsciiAnimation { - pub(crate) fn new(request_frame: FrameRequester) -> Self { - Self::with_variants(request_frame, ALL_VARIANTS, 0) - } - - pub(crate) fn with_variants( - request_frame: FrameRequester, - variants: &'static [&'static [&'static str]], - variant_idx: usize, - ) -> Self { - assert!( - !variants.is_empty(), - "AsciiAnimation requires at least one animation variant", - ); - let clamped_idx = variant_idx.min(variants.len() - 1); - Self { - request_frame, - variants, - variant_idx: clamped_idx, - frame_tick: FRAME_TICK_DEFAULT, - start: Instant::now(), - } - } - - pub(crate) fn schedule_next_frame(&self) { - let tick_ms = self.frame_tick.as_millis(); - if tick_ms == 0 { - self.request_frame.schedule_frame(); - return; - } - let elapsed_ms = self.start.elapsed().as_millis(); - let rem_ms = elapsed_ms % tick_ms; - let delay_ms = if rem_ms == 0 { - tick_ms - } else { - tick_ms - rem_ms - }; - if let Ok(delay_ms_u64) = u64::try_from(delay_ms) { - self.request_frame - .schedule_frame_in(Duration::from_millis(delay_ms_u64)); - } else { - self.request_frame.schedule_frame(); - } - } - - pub(crate) fn current_frame(&self) -> &'static str { - let frames = self.frames(); - if frames.is_empty() { - return ""; - } - let tick_ms = self.frame_tick.as_millis(); - if tick_ms == 0 { - return frames[0]; - } - let elapsed_ms = self.start.elapsed().as_millis(); - let idx = ((elapsed_ms / tick_ms) % frames.len() as u128) as usize; - frames[idx] - } - - pub(crate) fn pick_random_variant(&mut self) -> bool { - if self.variants.len() <= 1 { - return false; - } - let mut rng = rand::rng(); - let mut next = self.variant_idx; - while next == self.variant_idx { - next = rng.random_range(0..self.variants.len()); - } - self.variant_idx = next; - self.request_frame.schedule_frame(); - true - } - - #[allow(dead_code)] - pub(crate) fn request_frame(&self) { - self.request_frame.schedule_frame(); - } - - fn frames(&self) -> &'static [&'static str] { - self.variants[self.variant_idx] - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn frame_tick_must_be_nonzero() { - assert!(FRAME_TICK_DEFAULT.as_millis() > 0); - } -} diff --git a/codex-rs/tui2/src/bin/md-events2.rs b/codex-rs/tui2/src/bin/md-events2.rs deleted file mode 100644 index f1117fad91..0000000000 --- a/codex-rs/tui2/src/bin/md-events2.rs +++ /dev/null @@ -1,15 +0,0 @@ -use std::io::Read; -use std::io::{self}; - -fn main() { - let mut input = String::new(); - if let Err(err) = io::stdin().read_to_string(&mut input) { - eprintln!("failed to read stdin: {err}"); - std::process::exit(1); - } - - let parser = pulldown_cmark::Parser::new(&input); - for event in parser { - println!("{event:?}"); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/AGENTS.md b/codex-rs/tui2/src/bottom_pane/AGENTS.md deleted file mode 100644 index 44fac8e50a..0000000000 --- a/codex-rs/tui2/src/bottom_pane/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# TUI2 bottom pane (state machines) - -When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync: - -- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a - readable, top-down explanation of the current behavior. -- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter - handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). -- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence - is intentional and documented. - -Practical check: - -- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the - Enter/newline paths and `disable_paste_burst` semantics). diff --git a/codex-rs/tui2/src/bottom_pane/approval_overlay.rs b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs deleted file mode 100644 index 0f0445fee8..0000000000 --- a/codex-rs/tui2/src/bottom_pane/approval_overlay.rs +++ /dev/null @@ -1,727 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::BottomPaneView; -use crate::bottom_pane::CancellationEvent; -use crate::bottom_pane::list_selection_view::ListSelectionView; -use crate::bottom_pane::list_selection_view::SelectionItem; -use crate::bottom_pane::list_selection_view::SelectionViewParams; -use crate::diff_render::DiffSummary; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::history_cell; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::render::highlight::highlight_bash_to_lines; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use codex_core::features::Feature; -use codex_core::features::Features; -use codex_core::protocol::ElicitationAction; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::FileChange; -use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use mcp_types::RequestId; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; - -/// Request coming from the agent that needs user approval. -#[derive(Clone, Debug)] -pub(crate) enum ApprovalRequest { - Exec { - id: String, - command: Vec, - reason: Option, - proposed_execpolicy_amendment: Option, - }, - ApplyPatch { - id: String, - reason: Option, - cwd: PathBuf, - changes: HashMap, - }, - McpElicitation { - server_name: String, - request_id: RequestId, - message: String, - }, -} - -/// Modal overlay asking the user to approve or deny one or more requests. -pub(crate) struct ApprovalOverlay { - current_request: Option, - current_variant: Option, - queue: Vec, - app_event_tx: AppEventSender, - list: ListSelectionView, - options: Vec, - current_complete: bool, - done: bool, - features: Features, -} - -impl ApprovalOverlay { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { - let mut view = Self { - current_request: None, - current_variant: None, - queue: Vec::new(), - app_event_tx: app_event_tx.clone(), - list: ListSelectionView::new(Default::default(), app_event_tx), - options: Vec::new(), - current_complete: false, - done: false, - features, - }; - view.set_current(request); - view - } - - pub fn enqueue_request(&mut self, req: ApprovalRequest) { - self.queue.push(req); - } - - fn set_current(&mut self, request: ApprovalRequest) { - self.current_request = Some(request.clone()); - let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); - self.current_variant = Some(variant.clone()); - self.current_complete = false; - let (options, params) = Self::build_options(variant, header, &self.features); - self.options = options; - self.list = ListSelectionView::new(params, self.app_event_tx.clone()); - } - - fn build_options( - variant: ApprovalVariant, - header: Box, - features: &Features, - ) -> (Vec, SelectionViewParams) { - let (options, title) = match &variant { - ApprovalVariant::Exec { - proposed_execpolicy_amendment, - .. - } => ( - exec_options(proposed_execpolicy_amendment.clone(), features), - "Would you like to run the following command?".to_string(), - ), - ApprovalVariant::ApplyPatch { .. } => ( - patch_options(), - "Would you like to make the following edits?".to_string(), - ), - ApprovalVariant::McpElicitation { server_name, .. } => ( - elicitation_options(), - format!("{server_name} needs your approval."), - ), - }; - - let header = Box::new(ColumnRenderable::with([ - Line::from(title.bold()).into(), - Line::from("").into(), - header, - ])); - - let items = options - .iter() - .map(|opt| SelectionItem { - name: opt.label.clone(), - display_shortcut: opt - .display_shortcut - .or_else(|| opt.additional_shortcuts.first().copied()), - dismiss_on_select: false, - ..Default::default() - }) - .collect(); - - let params = SelectionViewParams { - footer_hint: Some(Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to confirm or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to cancel".into(), - ])), - items, - header, - ..Default::default() - }; - - (options, params) - } - - fn apply_selection(&mut self, actual_idx: usize) { - if self.current_complete { - return; - } - let Some(option) = self.options.get(actual_idx) else { - return; - }; - if let Some(variant) = self.current_variant.as_ref() { - match (variant, &option.decision) { - (ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { - self.handle_exec_decision(id, command, decision.clone()); - } - (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { - self.handle_patch_decision(id, decision.clone()); - } - ( - ApprovalVariant::McpElicitation { - server_name, - request_id, - }, - ApprovalDecision::McpElicitation(decision), - ) => { - self.handle_elicitation_decision(server_name, request_id, *decision); - } - _ => {} - } - } - - self.current_complete = true; - self.advance_queue(); - } - - fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); - self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { - id: id.to_string(), - decision, - })); - } - - fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) { - self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval { - id: id.to_string(), - decision, - })); - } - - fn handle_elicitation_decision( - &self, - server_name: &str, - request_id: &RequestId, - decision: ElicitationAction, - ) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::ResolveElicitation { - server_name: server_name.to_string(), - request_id: request_id.clone(), - decision, - })); - } - - fn advance_queue(&mut self) { - if let Some(next) = self.queue.pop() { - self.set_current(next); - } else { - self.done = true; - } - } - - fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { - match key_event { - KeyEvent { - kind: KeyEventKind::Press, - code: KeyCode::Char('a'), - modifiers, - .. - } if modifiers.contains(KeyModifiers::CONTROL) => { - if let Some(request) = self.current_request.as_ref() { - self.app_event_tx - .send(AppEvent::FullScreenApprovalRequest(request.clone())); - true - } else { - false - } - } - e => { - if let Some(idx) = self - .options - .iter() - .position(|opt| opt.shortcuts().any(|s| s.is_press(*e))) - { - self.apply_selection(idx); - true - } else { - false - } - } - } - } -} - -impl BottomPaneView for ApprovalOverlay { - fn handle_key_event(&mut self, key_event: KeyEvent) { - if self.try_handle_shortcut(&key_event) { - return; - } - self.list.handle_key_event(key_event); - if let Some(idx) = self.list.take_last_selected_index() { - self.apply_selection(idx); - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - if self.done { - return CancellationEvent::Handled; - } - if !self.current_complete - && let Some(variant) = self.current_variant.as_ref() - { - match &variant { - ApprovalVariant::Exec { id, command, .. } => { - self.handle_exec_decision(id, command, ReviewDecision::Abort); - } - ApprovalVariant::ApplyPatch { id, .. } => { - self.handle_patch_decision(id, ReviewDecision::Abort); - } - ApprovalVariant::McpElicitation { - server_name, - request_id, - } => { - self.handle_elicitation_decision( - server_name, - request_id, - ElicitationAction::Cancel, - ); - } - } - } - self.queue.clear(); - self.done = true; - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.done - } - - fn try_consume_approval_request( - &mut self, - request: ApprovalRequest, - ) -> Option { - self.enqueue_request(request); - None - } -} - -impl Renderable for ApprovalOverlay { - fn desired_height(&self, width: u16) -> u16 { - self.list.desired_height(width) - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - self.list.render(area, buf); - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.list.cursor_pos(area) - } -} - -struct ApprovalRequestState { - variant: ApprovalVariant, - header: Box, -} - -impl From for ApprovalRequestState { - fn from(value: ApprovalRequest) -> Self { - match value { - ApprovalRequest::Exec { - id, - command, - reason, - proposed_execpolicy_amendment, - } => { - let mut header: Vec> = Vec::new(); - if let Some(reason) = reason { - header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); - header.push(Line::from("")); - } - let full_cmd = strip_bash_lc_and_escape(&command); - let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd); - if let Some(first) = full_cmd_lines.first_mut() { - first.spans.insert(0, Span::from("$ ")); - } - header.extend(full_cmd_lines); - Self { - variant: ApprovalVariant::Exec { - id, - command, - proposed_execpolicy_amendment, - }, - header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), - } - } - ApprovalRequest::ApplyPatch { - id, - reason, - cwd, - changes, - } => { - let mut header: Vec> = Vec::new(); - if let Some(reason) = reason - && !reason.is_empty() - { - header.push(Box::new( - Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()])) - .wrap(Wrap { trim: false }), - )); - header.push(Box::new(Line::from(""))); - } - header.push(DiffSummary::new(changes, cwd).into()); - Self { - variant: ApprovalVariant::ApplyPatch { id }, - header: Box::new(ColumnRenderable::with(header)), - } - } - ApprovalRequest::McpElicitation { - server_name, - request_id, - message, - } => { - let header = Paragraph::new(vec![ - Line::from(vec!["Server: ".into(), server_name.clone().bold()]), - Line::from(""), - Line::from(message), - ]) - .wrap(Wrap { trim: false }); - Self { - variant: ApprovalVariant::McpElicitation { - server_name, - request_id, - }, - header: Box::new(header), - } - } - } - } -} - -#[derive(Clone)] -enum ApprovalVariant { - Exec { - id: String, - command: Vec, - proposed_execpolicy_amendment: Option, - }, - ApplyPatch { - id: String, - }, - McpElicitation { - server_name: String, - request_id: RequestId, - }, -} - -#[derive(Clone)] -enum ApprovalDecision { - Review(ReviewDecision), - McpElicitation(ElicitationAction), -} - -#[derive(Clone)] -struct ApprovalOption { - label: String, - decision: ApprovalDecision, - display_shortcut: Option, - additional_shortcuts: Vec, -} - -impl ApprovalOption { - fn shortcuts(&self) -> impl Iterator + '_ { - self.display_shortcut - .into_iter() - .chain(self.additional_shortcuts.iter().copied()) - } -} - -fn exec_options( - proposed_execpolicy_amendment: Option, - features: &Features, -) -> Vec { - vec![ApprovalOption { - label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }] - .into_iter() - .chain( - proposed_execpolicy_amendment - .filter(|_| features.enabled(Feature::ExecPolicy)) - .and_then(|prefix| { - let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); - if rendered_prefix.contains('\n') || rendered_prefix.contains('\r') { - return None; - } - - Some(ApprovalOption { - label: format!( - "Yes, and don't ask again for commands that start with `{rendered_prefix}`" - ), - decision: ApprovalDecision::Review( - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: prefix, - }, - ), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], - }) - }), - ) - .chain([ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }]) - .collect() -} - -fn patch_options() -> Vec { - vec![ - ApprovalOption { - label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }, - ApprovalOption { - label: "Yes, and don't ask again for these files".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], - }, - ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }, - ] -} - -fn elicitation_options() -> Vec { - vec![ - ApprovalOption { - label: "Yes, provide the requested info".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }, - ApprovalOption { - label: "No, but continue without it".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }, - ApprovalOption { - label: "Cancel this request".to_string(), - decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))], - }, - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use pretty_assertions::assert_eq; - use tokio::sync::mpsc::unbounded_channel; - - fn make_exec_request() -> ApprovalRequest { - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string(), "hi".to_string()], - reason: Some("reason".to_string()), - proposed_execpolicy_amendment: None, - } - } - - #[test] - fn ctrl_c_aborts_and_clears_queue() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); - view.enqueue_request(make_exec_request()); - assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); - assert!(view.queue.is_empty()); - assert!(view.is_complete()); - } - - #[test] - fn shortcut_triggers_selection() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); - assert!(!view.is_complete()); - view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - // We expect at least one CodexOp message in the queue. - let mut saw_op = false; - while let Ok(ev) = rx.try_recv() { - if matches!(ev, AppEvent::CodexOp(_)) { - saw_op = true; - break; - } - } - assert!(saw_op, "expected approval decision to emit an op"); - } - - #[test] - fn exec_prefix_option_emits_execpolicy_amendment() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string()], - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".to_string(), - ])), - }, - tx, - Features::with_defaults(), - ); - view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - let mut saw_op = false; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { - assert_eq!( - decision, - ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ - "echo".to_string() - ]) - } - ); - saw_op = true; - break; - } - } - assert!( - saw_op, - "expected approval decision to emit an op with command prefix" - ); - } - - #[test] - fn exec_prefix_option_hidden_when_execpolicy_disabled() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new( - ApprovalRequest::Exec { - id: "test".to_string(), - command: vec!["echo".to_string()], - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".to_string(), - ])), - }, - tx, - { - let mut features = Features::with_defaults(); - features.disable(Feature::ExecPolicy); - features - }, - ); - assert_eq!(view.options.len(), 2); - view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - assert!(!view.is_complete()); - assert!(rx.try_recv().is_err()); - } - - #[test] - fn header_includes_command_snippet() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - let command = vec!["echo".into(), "hello".into(), "world".into()]; - let exec_request = ApprovalRequest::Exec { - id: "test".into(), - command, - reason: None, - proposed_execpolicy_amendment: None, - }; - - let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); - let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); - view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); - - let rendered: Vec = (0..buf.area.height) - .map(|row| { - (0..buf.area.width) - .map(|col| buf[(col, row)].symbol().to_string()) - .collect() - }) - .collect(); - assert!( - rendered - .iter() - .any(|line| line.contains("echo hello world")), - "expected header to include command snippet, got {rendered:?}" - ); - } - - #[test] - fn exec_history_cell_wraps_with_two_space_indent() { - let command = vec![ - "/bin/zsh".into(), - "-lc".into(), - "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), - ]; - let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved); - let lines = cell.display_lines(28); - let rendered: Vec = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); - let expected = vec![ - "✔ You approved codex to run".to_string(), - " git add tui/src/render/".to_string(), - " mod.rs tui/src/render/".to_string(), - " renderable.rs this time".to_string(), - ]; - assert_eq!(rendered, expected); - } - - #[test] - fn enter_sets_last_selected_index_without_dismissing() { - let (tx_raw, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); - view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert!( - view.is_complete(), - "exec approval should complete without queued requests" - ); - - let mut decision = None; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev { - decision = Some(d); - break; - } - } - assert_eq!(decision, Some(ReviewDecision::Approved)); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs deleted file mode 100644 index 499801cbb0..0000000000 --- a/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::bottom_pane::ApprovalRequest; -use crate::render::renderable::Renderable; -use crossterm::event::KeyEvent; - -use super::CancellationEvent; - -/// Trait implemented by every view that can be shown in the bottom pane. -pub(crate) trait BottomPaneView: Renderable { - /// Handle a key event while the view is active. A redraw is always - /// scheduled after this call. - fn handle_key_event(&mut self, _key_event: KeyEvent) {} - - /// Return `true` if the view has finished and should be removed. - fn is_complete(&self) -> bool { - false - } - - /// Handle Ctrl-C while this view is active. - fn on_ctrl_c(&mut self) -> CancellationEvent { - CancellationEvent::NotHandled - } - - /// Optional paste handler. Return true if the view modified its state and - /// needs a redraw. - fn handle_paste(&mut self, _pasted: String) -> bool { - false - } - - /// Try to handle approval request; return the original value if not - /// consumed. - fn try_consume_approval_request( - &mut self, - request: ApprovalRequest, - ) -> Option { - Some(request) - } -} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs deleted file mode 100644 index 9f6b465510..0000000000 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ /dev/null @@ -1,4965 +0,0 @@ -//! The chat composer is the bottom-pane text input state machine. -//! -//! It is responsible for: -//! -//! - Editing the input buffer (a `TextArea`), including placeholder "elements" for attachments. -//! - Routing keys to the active popup (slash commands, file search, skill mentions). -//! - Handling submit vs newline on Enter. -//! - Turning raw key streams into explicit paste operations on platforms where terminals -//! don't provide reliable bracketed paste (notably Windows). -//! -//! # Key Event Routing -//! -//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a -//! popup-specific handler if a popup is visible and otherwise to -//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call -//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor. -//! -//! # Non-bracketed Paste Bursts -//! -//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of -//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event. -//! -//! To avoid misinterpreting these bursts as real typing, we feed "plain" character events into -//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them -//! through [`ChatComposer::handle_paste`]. -//! -//! The burst detector intentionally treats ASCII and non-ASCII differently: -//! -//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the -//! stream is paste-like. -//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow -//! burst detection for actual paste streams. -//! -//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state -//! machine and treats the key stream as normal typing. When toggling from enabled → disabled, the -//! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. -//! -//! For the detailed burst state machine, see `codex-rs/tui2/src/bottom_pane/paste_burst.rs`. -//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`. -//! -//! # PasteBurst Integration Points -//! -//! The burst detector is consulted in a few specific places: -//! -//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char -//! input to either buffer it or insert normally. -//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the -//! first char, while still allowing paste detection via retro-capture. -//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called -//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a -//! normal typed character. -//! -//! # Input Disabled Mode -//! -//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores -//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the -//! overall state machine, since it affects which transitions are even possible from a given UI -//! state. - -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::key_hint::has_ctrl_or_alt; -use crate::transcript_copy_action::TranscriptCopyFeedback; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Margin; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::WidgetRef; - -use super::chat_composer_history::ChatComposerHistory; -use super::command_popup::CommandItem; -use super::command_popup::CommandPopup; -use super::command_popup::CommandPopupFlags; -use super::file_search_popup::FileSearchPopup; -use super::footer::FooterMode; -use super::footer::FooterProps; -use super::footer::esc_hint_mode; -use super::footer::footer_height; -use super::footer::inset_footer_hint_area; -use super::footer::render_footer; -use super::footer::render_footer_hint_items; -use super::footer::reset_mode_after_activity; -use super::footer::toggle_shortcut_mode; -use super::paste_burst::CharDecision; -use super::paste_burst::PasteBurst; -use super::skill_popup::SkillPopup; -use crate::bottom_pane::paste_burst::FlushResult; -use crate::bottom_pane::prompt_args::expand_custom_prompt; -use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; -use crate::bottom_pane::prompt_args::parse_slash_name; -use crate::bottom_pane::prompt_args::prompt_argument_names; -use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; -use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; -use crate::render::Insets; -use crate::render::RectExt; -use crate::render::renderable::Renderable; -use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; -use crate::style::user_message_style; -use codex_common::fuzzy_match::fuzzy_match; -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use codex_protocol::models::local_image_label_text; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; -use crate::clipboard_paste::normalize_pasted_path; -use crate::clipboard_paste::pasted_image_format; -use crate::history_cell; -use crate::ui_consts::LIVE_PREFIX_COLS; -use codex_core::skills::model::SkillMetadata; -use codex_file_search::FileMatch; -use std::cell::RefCell; -use std::collections::HashMap; -use std::collections::HashSet; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; - -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - -/// If the pasted content exceeds this number of characters, replace it with a -/// placeholder in the UI. -const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; - -/// Result returned when the user interacts with the text area. -#[derive(Debug, PartialEq)] -pub enum InputResult { - Submitted(String), - Queued(String), - Command(SlashCommand), - CommandWithArgs(SlashCommand, String), - None, -} - -#[derive(Clone, Debug, PartialEq)] -struct AttachedImage { - placeholder: String, - path: PathBuf, -} - -enum PromptSelectionMode { - Completion, - Submit, -} - -enum PromptSelectionAction { - Insert { text: String, cursor: Option }, - Submit { text: String }, -} - -pub(crate) struct ChatComposer { - textarea: TextArea, - textarea_state: RefCell, - active_popup: ActivePopup, - app_event_tx: AppEventSender, - history: ChatComposerHistory, - quit_shortcut_expires_at: Option, - quit_shortcut_key: KeyBinding, - esc_backtrack_hint: bool, - use_shift_enter_hint: bool, - dismissed_file_popup_token: Option, - current_file_query: Option, - pending_pastes: Vec<(String, String)>, - large_paste_counters: HashMap, - has_focus: bool, - attached_images: Vec, - placeholder_text: String, - is_task_running: bool, - /// When false, the composer is temporarily read-only (e.g. during sandbox setup). - input_enabled: bool, - input_disabled_placeholder: Option, - /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). - paste_burst: PasteBurst, - // When true, disables paste-burst logic and inserts characters immediately. - disable_paste_burst: bool, - custom_prompts: Vec, - footer_mode: FooterMode, - footer_hint_override: Option>, - footer_flash: Option, - context_window_percent: Option, - context_window_used_tokens: Option, - transcript_scrolled: bool, - transcript_selection_active: bool, - transcript_scroll_position: Option<(usize, usize)>, - transcript_copy_selection_key: KeyBinding, - transcript_copy_feedback: Option, - skills: Option>, - dismissed_skill_popup_token: Option, - /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. - steer_enabled: bool, - collaboration_modes_enabled: bool, - connectors_enabled: bool, -} - -#[derive(Clone, Debug)] -struct FooterFlash { - line: Line<'static>, - expires_at: Instant, -} - -/// Popup state – at most one can be visible at any time. -enum ActivePopup { - None, - Command(CommandPopup), - File(FileSearchPopup), - Skill(SkillPopup), -} - -const FOOTER_SPACING_HEIGHT: u16 = 0; - -impl ChatComposer { - pub fn new( - has_input_focus: bool, - app_event_tx: AppEventSender, - enhanced_keys_supported: bool, - placeholder_text: String, - disable_paste_burst: bool, - ) -> Self { - let use_shift_enter_hint = enhanced_keys_supported; - - let mut this = Self { - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - active_popup: ActivePopup::None, - app_event_tx, - history: ChatComposerHistory::new(), - quit_shortcut_expires_at: None, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - esc_backtrack_hint: false, - use_shift_enter_hint, - dismissed_file_popup_token: None, - current_file_query: None, - pending_pastes: Vec::new(), - large_paste_counters: HashMap::new(), - has_focus: has_input_focus, - attached_images: Vec::new(), - placeholder_text, - is_task_running: false, - input_enabled: true, - input_disabled_placeholder: None, - paste_burst: PasteBurst::default(), - disable_paste_burst: false, - custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutSummary, - footer_hint_override: None, - footer_flash: None, - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - skills: None, - dismissed_skill_popup_token: None, - steer_enabled: false, - collaboration_modes_enabled: false, - connectors_enabled: false, - }; - // Apply configuration via the setter to keep side-effects centralized. - this.set_disable_paste_burst(disable_paste_burst); - this - } - - pub fn set_skill_mentions(&mut self, skills: Option>) { - self.skills = skills; - } - - /// Enables or disables "Steer" behavior for submission keys. - /// - /// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and - /// `Tab` produces [`InputResult::Queued`] (eligible to queue if a task is running). - /// When steer is disabled, `Enter` produces [`InputResult::Queued`], preserving the default - /// "queue while a task is running" behavior. - pub fn set_steer_enabled(&mut self, enabled: bool) { - self.steer_enabled = enabled; - } - - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { - self.collaboration_modes_enabled = enabled; - } - - pub fn set_connectors_enabled(&mut self, enabled: bool) { - self.connectors_enabled = enabled; - } - - fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - let popup_constraint = match &self.active_popup { - ActivePopup::Command(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) - } - ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::Skill(popup) => { - Constraint::Max(popup.calculate_required_height(area.width)) - } - ActivePopup::None => Constraint::Max(footer_total_height), - }; - let [composer_rect, popup_rect] = - Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); - let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); - [composer_rect, textarea_rect, popup_rect] - } - - fn footer_spacing(footer_hint_height: u16) -> u16 { - if footer_hint_height == 0 { - 0 - } else { - FOOTER_SPACING_HEIGHT - } - } - - /// Returns true if the composer currently contains no user input. - pub(crate) fn is_empty(&self) -> bool { - self.textarea.is_empty() - } - - /// Record the history metadata advertised by `SessionConfiguredEvent` so - /// that the composer can navigate cross-session history. - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history.set_metadata(log_id, entry_count); - } - - /// Integrate an asynchronous response to an on-demand history lookup. If - /// the entry is present and the offset matches the current cursor we - /// immediately populate the textarea. - pub(crate) fn on_history_entry_response( - &mut self, - log_id: u64, - offset: usize, - entry: Option, - ) -> bool { - let Some(text) = self.history.on_entry_response(log_id, offset, entry) else { - return false; - }; - self.set_text_content(text); - true - } - - /// Integrate pasted text into the composer. - /// - /// Acts as the only place where paste text is integrated, both for: - /// - /// - Real/explicit paste events surfaced by the terminal, and - /// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers - /// and later flushes here. - /// - /// Behavior: - /// - /// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder - /// element (expanded on submit) and stores the full text in `pending_pastes`. - /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a - /// trailing space so the user can keep typing naturally. - /// - Otherwise, inserts the pasted text directly into the textarea. - /// - /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect - /// the next user Enter key, then syncs popup state. - pub fn handle_paste(&mut self, pasted: String) -> bool { - let char_count = pasted.chars().count(); - if char_count > LARGE_PASTE_CHAR_THRESHOLD { - let placeholder = self.next_large_paste_placeholder(char_count); - self.textarea.insert_element(&placeholder); - self.pending_pastes.push((placeholder, pasted)); - } else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) { - self.textarea.insert_str(" "); - } else { - self.textarea.insert_str(&pasted); - } - // Explicit paste events should not trigger Enter suppression. - self.paste_burst.clear_after_explicit_paste(); - self.sync_popups(); - true - } - - pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { - let Some(path_buf) = normalize_pasted_path(&pasted) else { - return false; - }; - - // normalize_pasted_path already handles Windows → WSL path conversion, - // so we can directly try to read the image dimensions. - match image::image_dimensions(&path_buf) { - Ok((width, height)) => { - tracing::info!("OK: {pasted}"); - tracing::debug!("image dimensions={}x{}", width, height); - let format = pasted_image_format(&path_buf); - tracing::debug!("attached image format={}", format.label()); - self.attach_image(path_buf); - true - } - Err(err) => { - tracing::trace!("ERR: {err}"); - false - } - } - } - - /// Enable or disable paste-burst handling. - /// - /// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic - /// is unwanted or has already been handled elsewhere. - /// - /// When transitioning from enabled → disabled, we "defuse" any in-flight burst state so it - /// cannot affect subsequent normal typing: - /// - /// - First, flush any held/buffered text immediately via - /// [`PasteBurst::flush_before_modified_input`], and feed it through `handle_paste(String)`. - /// This preserves user input and routes it through the same integration path as explicit - /// pastes (large-paste placeholders, image-path detection, and popup sync). - /// - Then clear the burst timing and Enter-suppression window via - /// [`PasteBurst::clear_after_explicit_paste`]. - /// - /// We intentionally do not use `clear_window_after_non_char()` here: it clears timing state - /// without emitting any buffered text, which can leave a non-empty buffer unable to flush - /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). - pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { - let was_disabled = self.disable_paste_burst; - self.disable_paste_burst = disabled; - if disabled && !was_disabled { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - self.paste_burst.clear_after_explicit_paste(); - } - } - - /// Override the footer hint items displayed beneath the composer. Passing - /// `None` restores the default shortcut footer. - pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { - self.footer_hint_override = items; - } - - pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { - let expires_at = Instant::now() - .checked_add(duration) - .unwrap_or_else(Instant::now); - self.footer_flash = Some(FooterFlash { line, expires_at }); - } - - pub(crate) fn footer_flash_visible(&self) -> bool { - self.footer_flash - .as_ref() - .is_some_and(|flash| Instant::now() < flash.expires_at) - } - - /// Replace the entire composer content with `text` and reset cursor. - pub(crate) fn set_text_content(&mut self, text: String) { - // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text(""); - self.pending_pastes.clear(); - self.attached_images.clear(); - self.textarea.set_text(&text); - self.textarea.set_cursor(0); - self.sync_popups(); - } - - pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { - if self.is_empty() { - return None; - } - let previous = self.current_text(); - self.set_text_content(String::new()); - self.history.reset_navigation(); - self.history.record_local_submission(&previous); - Some(previous) - } - - /// Get the current composer text. - pub(crate) fn current_text(&self) -> String { - self.textarea.text().to_string() - } - - /// Insert an attachment placeholder and track it for the next submission. - pub fn attach_image(&mut self, path: PathBuf) { - let image_number = self.attached_images.len() + 1; - let placeholder = local_image_label_text(image_number); - // Insert as an element to match large paste placeholder behavior: - // styled distinctly and treated atomically for cursor/mutations. - self.textarea.insert_element(&placeholder); - self.attached_images - .push(AttachedImage { placeholder, path }); - } - - pub fn take_recent_submission_images(&mut self) -> Vec { - let images = std::mem::take(&mut self.attached_images); - images.into_iter().map(|img| img.path).collect() - } - - /// Flushes any due paste-burst state. - /// - /// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits: - /// - /// - If a burst times out, flush it via `handle_paste(String)`. - /// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it - /// as normal typed input. - /// - /// This also allows a single "held" ASCII char to render even when it turns out not to be part - /// of a paste burst. - pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - self.handle_paste_burst_flush(Instant::now()) - } - - /// Returns whether the composer is currently in any paste-burst related transient state. - /// - /// This includes actively buffering, having a non-empty burst buffer, or holding the first - /// ASCII char for flicker suppression. - pub(crate) fn is_in_paste_burst(&self) -> bool { - self.paste_burst.is_active() - } - - /// Returns a delay that reliably exceeds the paste-burst timing threshold. - /// - /// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout. - pub(crate) fn recommended_paste_flush_delay() -> Duration { - PasteBurst::recommended_flush_delay() - } - - /// Integrate results from an asynchronous file search. - pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { - // Only apply if user is still editing a token starting with `query`. - let current_opt = Self::current_at_token(&self.textarea); - let Some(current_token) = current_opt else { - return; - }; - - if !current_token.starts_with(&query) { - return; - } - - if let ActivePopup::File(popup) = &mut self.active_popup { - popup.set_matches(&query, matches); - } - } - - /// Show the transient "press again to quit" hint for `key`. - /// - /// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a - /// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear - /// even when the UI is otherwise idle. - pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) { - self.quit_shortcut_expires_at = Instant::now() - .checked_add(super::QUIT_SHORTCUT_TIMEOUT) - .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = key; - self.footer_mode = FooterMode::QuitShortcutReminder; - self.set_has_focus(has_focus); - } - - /// Clear the "press again to quit" hint immediately. - pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { - self.quit_shortcut_expires_at = None; - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.set_has_focus(has_focus); - } - - /// Whether the quit shortcut hint should currently be shown. - /// - /// This is time-based rather than event-based: it may become false without - /// any additional user input, so the UI schedules a redraw when the hint - /// expires. - pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.quit_shortcut_expires_at - .is_some_and(|expires_at| Instant::now() < expires_at) - } - - fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { - let base = format!("[Pasted Content {char_count} chars]"); - let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); - *next_suffix += 1; - if *next_suffix == 1 { - base - } else { - format!("{base} #{next_suffix}") - } - } - - pub(crate) fn insert_str(&mut self, text: &str) { - self.textarea.insert_str(text); - self.sync_popups(); - } - - /// Handle a key event coming from the main UI. - pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if !self.input_enabled { - return (InputResult::None, false); - } - - let result = match &mut self.active_popup { - ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), - ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), - ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), - ActivePopup::None => self.handle_key_event_without_popup(key_event), - }; - - // Update (or hide/show) popup after processing the key. - self.sync_popups(); - - result - } - - /// Return true if either the slash-command popup or the file-search popup is active. - pub(crate) fn popup_active(&self) -> bool { - !matches!(self.active_popup, ActivePopup::None) - } - - /// Handle key event when the slash-command popup is visible. - fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - let ActivePopup::Command(popup) = &mut self.active_popup else { - unreachable!(); - }; - - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_up(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_down(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - // Dismiss the slash popup; keep the current input untouched. - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - 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 mut cursor_target: Option = None; - match sel { - CommandItem::Builtin(cmd) => { - if cmd == SlashCommand::Skills { - self.textarea.set_text(""); - return (InputResult::Command(cmd), true); - } - - let starts_with_cmd = first_line - .trim_start() - .starts_with(&format!("/{}", cmd.command())); - if !starts_with_cmd { - self.textarea.set_text(&format!("/{} ", cmd.command())); - } - if !self.textarea.text().is_empty() { - cursor_target = Some(self.textarea.text().len()); - } - } - CommandItem::UserPrompt(idx) => { - if let Some(prompt) = popup.prompt(idx) { - match prompt_selection_action( - prompt, - first_line, - PromptSelectionMode::Completion, - ) { - PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); - cursor_target = Some(target); - } - PromptSelectionAction::Submit { .. } => {} - } - } - } - } - if let Some(pos) = cursor_target { - self.textarea.set_cursor(pos); - } - } - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - // If the current line starts with a custom prompt name and includes - // positional args for a numeric-style template, expand and submit - // immediately regardless of the popup selection. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, _rest)) = parse_slash_name(first_line) - && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) - && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) - && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) - { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); - } - - if let Some(sel) = popup.selected_item() { - match sel { - CommandItem::Builtin(cmd) => { - self.textarea.set_text(""); - return (InputResult::Command(cmd), true); - } - CommandItem::UserPrompt(idx) => { - if let Some(prompt) = popup.prompt(idx) { - match prompt_selection_action( - prompt, - first_line, - PromptSelectionMode::Submit, - ) { - PromptSelectionAction::Submit { text } => { - self.textarea.set_text(""); - return (InputResult::Submitted(text), true); - } - PromptSelectionAction::Insert { text, cursor } => { - let target = cursor.unwrap_or(text.len()); - self.textarea.set_text(&text); - self.textarea.set_cursor(target); - return (InputResult::None, true); - } - } - } - return (InputResult::None, true); - } - } - } - // Fallback to default newline handling if no command selected. - self.handle_key_event_without_popup(key_event) - } - input => self.handle_input_basic(input), - } - } - - #[inline] - /// Clamp a cursor index to a UTF-8 char boundary. - fn clamp_to_char_boundary(text: &str, pos: usize) -> usize { - let mut p = pos.min(text.len()); - if p < text.len() && !text.is_char_boundary(p) { - p = text - .char_indices() - .map(|(i, _)| i) - .take_while(|&i| i <= p) - .last() - .unwrap_or(0); - } - p - } - - /// Handle non-ASCII character input (often IME) while still supporting paste-burst detection. - /// - /// This handler exists because non-ASCII input often comes from IMEs, where characters can - /// legitimately arrive in short bursts that should **not** be treated as paste. - /// - /// The key differences from the ASCII path: - /// - /// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a - /// non-ASCII char can feel like dropped input. - /// - If a burst is detected, we may need to retroactively remove already-inserted text before - /// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`). - /// - /// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp - /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. - #[inline] - fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { - if self.disable_paste_burst { - // When burst detection is disabled, treat IME/non-ASCII input as normal typing. - // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes - .retain(|(placeholder, _)| text_after.contains(placeholder)); - return (InputResult::None, true); - } - if let KeyEvent { - code: KeyCode::Char(ch), - .. - } = input - { - let now = Instant::now(); - if self.paste_burst.try_append_char_if_active(ch, now) { - return (InputResult::None, true); - } - // Non-ASCII input often comes from IMEs and can arrive in quick bursts. - // We do not want to hold the first char (flicker suppression) on this path, but we - // still want to detect paste-like bursts. Before applying any non-ASCII input, flush - // any existing burst buffer (including a pending first char from the ASCII path) so - // we don't carry that transient state forward. - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) { - match decision { - CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - CharDecision::BeginBuffer { retro_chars } => { - // For non-ASCII we inserted prior chars immediately, so if this turns out - // to be paste-like we need to retroactively grab & remove the already- - // inserted prefix from the textarea before buffering the burst. - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); - let safe_cur = Self::clamp_to_char_boundary(txt, cur); - let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { - if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); - } - // seed the paste burst buffer with everything (grabbed + new) - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - // If decide_begin_buffer opted not to start buffering, - // fall through to normal insertion below. - } - _ => unreachable!("on_plain_char_no_hold returned unexpected variant"), - } - } - } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes - .retain(|(placeholder, _)| text_after.contains(placeholder)); - (InputResult::None, true) - } - - /// Handle key events when file search popup is visible. - fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - let ActivePopup::File(popup) = &mut self.active_popup else { - unreachable!(); - }; - - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_up(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_down(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - // Hide popup without modifying text, remember token to avoid immediate reopen. - if let Some(tok) = Self::current_at_token(&self.textarea) { - self.dismissed_file_popup_token = Some(tok); - } - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Tab, .. - } - | KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let Some(sel) = popup.selected_match() else { - self.active_popup = ActivePopup::None; - return (InputResult::None, true); - }; - - let sel_path = sel.to_string(); - // If selected path looks like an image (png/jpeg), attach as image instead of inserting text. - let is_image = Self::is_image_path(&sel_path); - if is_image { - // Determine dimensions; if that fails fall back to normal path insertion. - let path_buf = PathBuf::from(&sel_path); - match image::image_dimensions(&path_buf) { - Ok((width, height)) => { - tracing::debug!("selected image dimensions={}x{}", width, height); - // Remove the current @token (mirror logic from insert_selected_path without inserting text) - // using the flat text and byte-offset cursor API. - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Determine token boundaries in the full text. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); - - self.attach_image(path_buf); - // Add a trailing space to keep typing fluid. - self.textarea.insert_str(" "); - } - Err(err) => { - tracing::trace!("image dimensions lookup failed: {err}"); - // Fallback to plain path insertion if metadata read fails. - self.insert_selected_path(&sel_path); - } - } - } else { - // Non-image: inserting file path. - self.insert_selected_path(&sel_path); - } - // No selection: treat Enter as closing the popup/session. - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - input => self.handle_input_basic(input), - } - } - - fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - self.footer_mode = reset_mode_after_activity(self.footer_mode); - - let ActivePopup::Skill(popup) = &mut self.active_popup else { - unreachable!(); - }; - - match key_event { - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_up(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - popup.move_down(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - if let Some(tok) = self.current_skill_token() { - self.dismissed_skill_popup_token = Some(tok); - } - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Tab, .. - } - | KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let selected = popup.selected_skill().map(|skill| skill.name.clone()); - if let Some(name) = selected { - self.insert_selected_skill(&name); - } - self.active_popup = ActivePopup::None; - (InputResult::None, true) - } - input => self.handle_input_basic(input), - } - } - - fn is_image_path(path: &str) -> bool { - let lower = path.to_ascii_lowercase(); - lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") - } - - fn skills_enabled(&self) -> bool { - self.skills.as_ref().is_some_and(|s| !s.is_empty()) - } - - pub fn skills(&self) -> Option<&Vec> { - self.skills.as_ref() - } - - /// Extract a token prefixed with `prefix` under the cursor, if any. - /// - /// The returned string **does not** include the prefix. - /// - /// Behavior: - /// - The cursor may be anywhere *inside* the token (including on the - /// leading prefix). It does **not** need to be at the end of the line. - /// - A token is delimited by ASCII whitespace (space, tab, newline). - /// - If the token under the cursor starts with `prefix`, that token is - /// returned without the leading prefix. When `allow_empty` is true, a - /// lone prefix character yields `Some(String::new())` to surface hints. - fn current_prefixed_token( - textarea: &TextArea, - prefix: char, - allow_empty: bool, - ) -> Option { - let cursor_offset = textarea.cursor(); - let text = textarea.text(); - - // Adjust the provided byte offset to the nearest valid char boundary at or before it. - let mut safe_cursor = cursor_offset.min(text.len()); - // If we're not on a char boundary, move back to the start of the current char. - if safe_cursor < text.len() && !text.is_char_boundary(safe_cursor) { - // Find the last valid boundary <= cursor_offset. - safe_cursor = text - .char_indices() - .map(|(i, _)| i) - .take_while(|&i| i <= cursor_offset) - .last() - .unwrap_or(0); - } - - // Split the line around the (now safe) cursor position. - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Detect whether we're on whitespace at the cursor boundary. - let at_whitespace = if safe_cursor < text.len() { - text[safe_cursor..] - .chars() - .next() - .map(char::is_whitespace) - .unwrap_or(false) - } else { - false - }; - - // Left candidate: token containing the cursor position. - let start_left = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_left_rel = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_left = safe_cursor + end_left_rel; - let token_left = if start_left < end_left { - Some(&text[start_left..end_left]) - } else { - None - }; - - // Right candidate: token immediately after any whitespace from the cursor. - let ws_len_right: usize = after_cursor - .chars() - .take_while(|c| c.is_whitespace()) - .map(char::len_utf8) - .sum(); - let start_right = safe_cursor + ws_len_right; - let end_right_rel = text[start_right..] - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(text.len() - start_right); - let end_right = start_right + end_right_rel; - let token_right = if start_right < end_right { - Some(&text[start_right..end_right]) - } else { - None - }; - - let prefix_str = prefix.to_string(); - let left_match = token_left.filter(|t| t.starts_with(prefix)); - let right_match = token_right.filter(|t| t.starts_with(prefix)); - - let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); - let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); - - if at_whitespace { - if right_prefixed.is_some() { - return right_prefixed; - } - if token_left.is_some_and(|t| t == prefix_str) { - return allow_empty.then(String::new); - } - return left_prefixed; - } - if after_cursor.starts_with(prefix) { - return right_prefixed.or(left_prefixed); - } - left_prefixed.or(right_prefixed) - } - - /// Extract the `@token` that the cursor is currently positioned on, if any. - /// - /// The returned string **does not** include the leading `@`. - fn current_at_token(textarea: &TextArea) -> Option { - Self::current_prefixed_token(textarea, '@', false) - } - - fn current_skill_token(&self) -> Option { - if !self.skills_enabled() { - return None; - } - Self::current_prefixed_token(&self.textarea, '$', true) - } - - /// Replace the active `@token` (the one under the cursor) with `path`. - /// - /// The algorithm mirrors `current_at_token` so replacement works no matter - /// where the cursor is within the token and regardless of how many - /// `@tokens` exist in the line. - fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Determine token boundaries. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - // If the path contains whitespace, wrap it in double quotes so the - // local prompt arg parser treats it as a single argument. Avoid adding - // quotes when the path already contains one to keep behavior simple. - let needs_quotes = path.chars().any(char::is_whitespace); - let inserted = if needs_quotes && !path.contains('"') { - format!("\"{path}\"") - } else { - path.to_string() - }; - - // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. - let mut new_text = - String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); - new_text.push_str(&text[..start_idx]); - new_text.push_str(&inserted); - new_text.push(' '); - new_text.push_str(&text[end_idx..]); - - self.textarea.set_text(&new_text); - let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); - } - - fn insert_selected_skill(&mut self, skill_name: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - let inserted = format!("${skill_name}"); - - let mut new_text = - String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); - new_text.push_str(&text[..start_idx]); - new_text.push_str(&inserted); - new_text.push(' '); - new_text.push_str(&text[end_idx..]); - - self.textarea.set_text(&new_text); - let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); - } - - /// Prepare text for submission/queuing. Returns None if submission should be suppressed. - fn prepare_submission_text(&mut self) -> Option { - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - - let mut text = self.textarea.text().to_string(); - let original_input = text.clone(); - let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); - - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - 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 let Some((name, _rest)) = parse_slash_name(&text) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = Self::built_in_slash_commands_for_input( - self.collaboration_modes_enabled, - self.connectors_enabled, - ) - .any(|(command_name, _)| command_name == name); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return None; - } - } - } - - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return None; - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded; - } - if text.is_empty() && !has_attachments { - return None; - } - if !text.is_empty() { - self.history.record_local_submission(&text); - } - Some(text) - } - - /// Common logic for handling message submission/queuing. - /// Returns the appropriate InputResult based on `should_queue`. - fn handle_submission(&mut self, should_queue: bool) -> (InputResult, bool) { - // If the first line is a bare built-in slash command (no args), - // dispatch it even when the slash popup isn't visible. This preserves - // the workflow: type a prefix ("/di"), press Tab to complete to - // "/diff ", then press Enter/Ctrl+Shift+Q to run it. Tab moves the cursor beyond - // the '/name' token and our caret-based heuristic hides the popup, - // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit - // literal text. - if let Some(result) = self.try_dispatch_bare_slash_command() { - return (result, true); - } - - // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); - if !self.disable_paste_burst && self.paste_burst.is_active() && !in_slash_context { - let now = Instant::now(); - if self.paste_burst.append_newline_if_active(now) { - return (InputResult::None, true); - } - } - - // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. - let now = Instant::now(); - if !in_slash_context - && !self.disable_paste_burst - && self - .paste_burst - .newline_should_insert_instead_of_submit(now) - { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); - return (InputResult::None, true); - } - - let original_input = self.textarea.text().to_string(); - if let Some(result) = self.try_dispatch_slash_command_with_args() { - return (result, true); - } - - if let Some(text) = self.prepare_submission_text() { - if should_queue { - (InputResult::Queued(text), true) - } else { - // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) - } - } else { - // Restore text if submission was suppressed - self.textarea.set_text(&original_input); - (InputResult::None, true) - } - } - - /// Check if the first line is a bare slash command (no args) and dispatch it. - /// Returns Some(InputResult) if a command was dispatched, None otherwise. - fn try_dispatch_bare_slash_command(&mut self) -> Option { - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( - self.collaboration_modes_enabled, - self.connectors_enabled, - ) - .find(|(n, _)| *n == name) - { - self.textarea.set_text(""); - Some(InputResult::Command(cmd)) - } else { - None - } - } - - /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. - /// Returns Some(InputResult) if a command was dispatched, None otherwise. - fn try_dispatch_slash_command_with_args(&mut self) -> Option { - let original_input = self.textarea.text().to_string(); - let input_starts_with_space = original_input.starts_with(' '); - - if !input_starts_with_space { - let text = self.textarea.text().to_string(); - if let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( - self.collaboration_modes_enabled, - self.connectors_enabled, - ) - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - self.textarea.set_text(""); - return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); - } - } - None - } - - /// Handle key event when no popup is visible. - fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - if self.is_empty() { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - match key_event { - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } if self.is_empty() => (InputResult::None, false), - // ------------------------------------------------------------- - // History navigation (Up / Down) – only when the composer is not - // empty or when the cursor is at the correct position, to avoid - // interfering with normal cursor movement. - // ------------------------------------------------------------- - KeyEvent { - code: KeyCode::Up | KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('p') | KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if self - .history - .should_handle_navigation(self.textarea.text(), self.textarea.cursor()) - { - let replace_text = match key_event.code { - KeyCode::Up => self.history.navigate_up(&self.app_event_tx), - KeyCode::Down => self.history.navigate_down(&self.app_event_tx), - KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx), - KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx), - _ => unreachable!(), - }; - if let Some(text) = replace_text { - self.set_text_content(text); - return (InputResult::None, true); - } - } - self.handle_input_basic(key_event) - } - KeyEvent { - code: KeyCode::Tab, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - .. - } => self.handle_submission(true), - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let should_queue = !self.steer_enabled; - self.handle_submission(should_queue) - } - input => self.handle_input_basic(input), - } - } - - /// Applies any due `PasteBurst` flush at time `now`. - /// - /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. - /// - /// Callers: - /// - /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. - /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. - 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()); - self.sync_popups(); - true - } - FlushResult::None => false, - } - } - - /// Handles keys that mutate the textarea, including paste-burst detection. - /// - /// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain - /// character streams are converted into explicit paste operations on terminals that do not - /// reliably provide bracketed paste. - /// - /// Ordering is important: - /// - /// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated - /// edits. - /// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input. - /// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key; - /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a - /// timestamp to time out against. - 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(); - self.handle_paste_burst_flush(now); - - if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - - // If we're capturing a burst and receive Enter, accumulate it instead of inserting. - if matches!(input.code, KeyCode::Enter) - && !self.disable_paste_burst - && self.paste_burst.is_active() - && self.paste_burst.append_newline_if_active(now) - { - return (InputResult::None, true); - } - - // Intercept plain Char inputs to optionally accumulate into a burst buffer. - // - // This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their - // normal semantics, and so we can aggressively flush/clear any burst state when non-char - // keys are pressed. - if let KeyEvent { - code: KeyCode::Char(ch), - modifiers, - .. - } = input - { - let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst { - // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid - // holding the first char while still allowing burst detection for paste input. - if !ch.is_ascii() { - return self.handle_non_ascii_char(input); - } - - match self.paste_burst.on_plain_char(ch, now) { - CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - CharDecision::BeginBuffer { retro_chars } => { - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); - let safe_cur = Self::clamp_to_char_boundary(txt, cur); - let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { - if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); - } - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - // If decide_begin_buffer opted not to start buffering, - // fall through to normal insertion below. - } - CharDecision::BeginBufferFromPending => { - // First char was held; now append the current one. - self.paste_burst.append_char_to_buffer(ch, now); - return (InputResult::None, true); - } - CharDecision::RetainFirstChar => { - // Keep the first fast char pending momentarily. - return (InputResult::None, true); - } - } - } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { - self.handle_paste(pasted); - } - } - - // Flush any buffered burst before applying a non-char input (arrow keys, etc). - // - // `clear_window_after_non_char()` clears `last_plain_char_time`. If we cleared that while - // `PasteBurst.buffer` is non-empty, `flush_if_due()` would no longer have a timestamp to - // time out against, and the buffered paste could remain stuck until another plain char - // arrives. - if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) - && let Some(pasted) = self.paste_burst.flush_before_modified_input() - { - self.handle_paste(pasted); - } - - // Backspace at the start of an image placeholder should delete that placeholder (rather - // than deleting content before it). Do this without scanning the full text by consulting - // the textarea's element list. - if matches!(input.code, KeyCode::Backspace) - && self.try_remove_image_element_at_cursor_start() - { - return (InputResult::None, true); - } - - // Track element removals so we can drop any corresponding placeholders without scanning - // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) - let elements_before = if self.pending_pastes.is_empty() && self.attached_images.is_empty() { - None - } else { - Some(self.textarea.element_payloads()) - }; - - self.textarea.input(input); - - if let Some(elements_before) = elements_before { - self.reconcile_deleted_elements(elements_before); - } - - // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. - let crossterm::event::KeyEvent { - code, modifiers, .. - } = input; - match code { - KeyCode::Char(_) => { - let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if has_ctrl_or_alt { - self.paste_burst.clear_window_after_non_char(); - } - } - KeyCode::Enter => { - // Keep burst window alive (supports blank lines in paste). - } - _ => { - // Other keys: clear burst window (buffer should have been flushed above if needed). - self.paste_burst.clear_window_after_non_char(); - } - } - - (InputResult::None, true) - } - - fn try_remove_image_element_at_cursor_start(&mut self) -> bool { - if self.attached_images.is_empty() { - return false; - } - - let p = self.textarea.cursor(); - let Some(payload) = self.textarea.element_payload_starting_at(p) else { - return false; - }; - let Some(idx) = self - .attached_images - .iter() - .position(|img| img.placeholder == payload) - else { - return false; - }; - - self.textarea.replace_range(p..p + payload.len(), ""); - self.attached_images.remove(idx); - self.relabel_attached_images_and_update_placeholders(); - true - } - - fn reconcile_deleted_elements(&mut self, elements_before: Vec) { - let elements_after: HashSet = - self.textarea.element_payloads().into_iter().collect(); - - let mut removed_any_image = false; - for removed in elements_before - .into_iter() - .filter(|payload| !elements_after.contains(payload)) - { - self.pending_pastes.retain(|(ph, _)| ph != &removed); - - if let Some(idx) = self - .attached_images - .iter() - .position(|img| img.placeholder == removed) - { - self.attached_images.remove(idx); - removed_any_image = true; - } - } - - if removed_any_image { - self.relabel_attached_images_and_update_placeholders(); - } - } - - fn relabel_attached_images_and_update_placeholders(&mut self) { - for idx in 0..self.attached_images.len() { - let expected = local_image_label_text(idx + 1); - let current = self.attached_images[idx].placeholder.clone(); - if current == expected { - continue; - } - - self.attached_images[idx].placeholder = expected.clone(); - let _renamed = self.textarea.replace_element_payload(¤t, &expected); - } - } - - fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind != KeyEventKind::Press { - return false; - } - - let toggles = matches!(key_event.code, KeyCode::Char('?')) - && !has_ctrl_or_alt(key_event.modifiers) - && self.is_empty() - && !self.is_in_paste_burst(); - - if !toggles { - return false; - } - - let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible()); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed - } - - fn footer_props(&self) -> FooterProps { - FooterProps { - mode: self.footer_mode(), - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, - is_task_running: self.is_task_running, - quit_shortcut_key: self.quit_shortcut_key, - steer_enabled: self.steer_enabled, - collaboration_modes_enabled: self.collaboration_modes_enabled, - context_window_percent: self.context_window_percent, - context_window_used_tokens: self.context_window_used_tokens, - transcript_scrolled: self.transcript_scrolled, - transcript_selection_active: self.transcript_selection_active, - transcript_scroll_position: self.transcript_scroll_position, - transcript_copy_selection_key: self.transcript_copy_selection_key, - transcript_copy_feedback: self.transcript_copy_feedback, - } - } - - fn footer_mode(&self) -> FooterMode { - match self.footer_mode { - FooterMode::EscHint => FooterMode::EscHint, - FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, - FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => { - FooterMode::QuitShortcutReminder - } - FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary, - FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => { - FooterMode::QuitShortcutReminder - } - FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, - other => other, - } - } - - fn custom_footer_height(&self) -> Option { - if self.footer_flash_visible() { - return Some(1); - } - self.footer_hint_override - .as_ref() - .map(|items| if items.is_empty() { 0 } else { 1 }) - } - - /// Update the footer's view of transcript scroll state for the inline viewport. - /// - /// This state is derived from the main `App`'s transcript viewport and passed - /// through the bottom pane so the footer can indicate when the transcript is - /// scrolled away from the bottom, whether a selection is active, and the - /// current `(visible_top, total)` position. - pub(crate) fn set_transcript_ui_state( - &mut self, - scrolled: bool, - selection_active: bool, - scroll_position: Option<(usize, usize)>, - copy_selection_key: KeyBinding, - copy_feedback: Option, - ) -> bool { - if self.transcript_scrolled == scrolled - && self.transcript_selection_active == selection_active - && self.transcript_scroll_position == scroll_position - && self.transcript_copy_selection_key == copy_selection_key - && self.transcript_copy_feedback == copy_feedback - { - return false; - } - - self.transcript_scrolled = scrolled; - self.transcript_selection_active = selection_active; - self.transcript_scroll_position = scroll_position; - self.transcript_copy_selection_key = copy_selection_key; - self.transcript_copy_feedback = copy_feedback; - true - } - - fn sync_popups(&mut self) { - let file_token = Self::current_at_token(&self.textarea); - let browsing_history = self - .history - .should_handle_navigation(self.textarea.text(), self.textarea.cursor()); - // When browsing input history (shell-style Up/Down recall), skip all popup - // synchronization so nothing steals focus from continued history navigation. - if browsing_history { - self.active_popup = ActivePopup::None; - return; - } - let skill_token = self.current_skill_token(); - - let allow_command_popup = file_token.is_none() && skill_token.is_none(); - self.sync_command_popup(allow_command_popup); - - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - self.dismissed_skill_popup_token = None; - return; - } - - if let Some(token) = skill_token { - self.sync_skill_popup(token); - return; - } - self.dismissed_skill_popup_token = None; - - if let Some(token) = file_token { - self.sync_file_search_popup(token); - return; - } - - self.dismissed_file_popup_token = None; - if matches!( - self.active_popup, - ActivePopup::File(_) | ActivePopup::Skill(_) - ) { - self.active_popup = ActivePopup::None; - } - } - - /// If the cursor is currently within a slash command on the first line, - /// extract the command name and the rest of the line after it. - /// Returns None if the cursor is outside a slash command. - fn slash_command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { - if !first_line.starts_with('/') { - return None; - } - - let name_start = 1usize; - let name_end = first_line[name_start..] - .find(char::is_whitespace) - .map(|idx| name_start + idx) - .unwrap_or_else(|| first_line.len()); - - if cursor > name_end { - return None; - } - - let name = &first_line[name_start..name_end]; - let rest_start = first_line[name_end..] - .find(|c: char| !c.is_whitespace()) - .map(|idx| name_end + idx) - .unwrap_or(name_end); - let rest = &first_line[rest_start..]; - - Some((name, rest)) - } - - /// Heuristic for whether the typed slash command looks like a valid - /// prefix for any known command (built-in or custom prompt). - /// Empty names only count when there is no extra content after the '/'. - fn looks_like_slash_prefix(&self, name: &str, rest_after_name: &str) -> bool { - if name.is_empty() { - return rest_after_name.is_empty(); - } - - let builtin_match = Self::built_in_slash_commands_for_input( - self.collaboration_modes_enabled, - self.connectors_enabled, - ) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); - - if builtin_match { - return true; - } - - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - self.custom_prompts - .iter() - .any(|p| fuzzy_match(&format!("{prompt_prefix}{}", p.name), name).is_some()) - } - - /// Synchronize `self.command_popup` with the current text in the - /// textarea. This must be called after every modification that can change - /// the text so the popup is shown/updated/hidden as appropriate. - fn sync_command_popup(&mut self, allow: bool) { - if !allow { - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.active_popup = ActivePopup::None; - } - return; - } - // Determine whether the caret is inside the initial '/name' token on the first line. - let text = self.textarea.text(); - let first_line_end = text.find('\n').unwrap_or(text.len()); - let first_line = &text[..first_line_end]; - let cursor = self.textarea.cursor(); - let caret_on_first_line = cursor <= first_line_end; - - let is_editing_slash_command_name = caret_on_first_line - && Self::slash_command_under_cursor(first_line, cursor) - .is_some_and(|(name, rest)| self.looks_like_slash_prefix(name, rest)); - - // If the cursor is currently positioned within an `@token`, prefer the - // file-search popup over the slash popup so users can insert a file path - // as an argument to the command (e.g., "/review @docs/..."). - if Self::current_at_token(&self.textarea).is_some() { - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.active_popup = ActivePopup::None; - } - return; - } - match &mut self.active_popup { - ActivePopup::Command(popup) => { - if is_editing_slash_command_name { - popup.on_composer_text_change(first_line.to_string()); - } else { - self.active_popup = ActivePopup::None; - } - } - _ => { - if is_editing_slash_command_name { - let skills_enabled = self.skills_enabled(); - let collaboration_modes_enabled = self.collaboration_modes_enabled; - let connectors_enabled = self.connectors_enabled; - let mut command_popup = CommandPopup::new( - self.custom_prompts.clone(), - CommandPopupFlags { - skills_enabled, - collaboration_modes_enabled, - connectors_enabled, - }, - ); - command_popup.on_composer_text_change(first_line.to_string()); - self.active_popup = ActivePopup::Command(command_popup); - } - } - } - } - - fn built_in_slash_commands_for_input( - collaboration_modes_enabled: bool, - connectors_enabled: bool, - ) -> impl Iterator { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - built_in_slash_commands() - .into_iter() - .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .filter(move |(_, cmd)| connectors_enabled || *cmd != SlashCommand::Connectors) - } - - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { - self.custom_prompts = prompts.clone(); - if let ActivePopup::Command(popup) = &mut self.active_popup { - popup.set_prompts(prompts); - } - } - - /// Synchronize `self.file_search_popup` with the current text in the textarea. - /// Note this is only called when self.active_popup is NOT Command. - fn sync_file_search_popup(&mut self, query: String) { - // If user dismissed popup for this exact query, don't reopen until text changes. - if self.dismissed_file_popup_token.as_ref() == Some(&query) { - return; - } - - if !query.is_empty() { - self.app_event_tx - .send(AppEvent::StartFileSearch(query.clone())); - } - - match &mut self.active_popup { - ActivePopup::File(popup) => { - if query.is_empty() { - popup.set_empty_prompt(); - } else { - popup.set_query(&query); - } - } - _ => { - let mut popup = FileSearchPopup::new(); - if query.is_empty() { - popup.set_empty_prompt(); - } else { - popup.set_query(&query); - } - self.active_popup = ActivePopup::File(popup); - } - } - - self.current_file_query = Some(query); - self.dismissed_file_popup_token = None; - } - - fn sync_skill_popup(&mut self, query: String) { - if self.dismissed_skill_popup_token.as_ref() == Some(&query) { - return; - } - - let skills = match self.skills.as_ref() { - Some(skills) if !skills.is_empty() => skills.clone(), - _ => { - self.active_popup = ActivePopup::None; - return; - } - }; - - match &mut self.active_popup { - ActivePopup::Skill(popup) => { - popup.set_query(&query); - popup.set_skills(skills); - } - _ => { - let mut popup = SkillPopup::new(skills); - popup.set_query(&query); - self.active_popup = ActivePopup::Skill(popup); - } - } - } - - fn set_has_focus(&mut self, has_focus: bool) { - self.has_focus = has_focus; - } - - #[allow(dead_code)] - pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { - self.input_enabled = enabled; - self.input_disabled_placeholder = if enabled { None } else { placeholder }; - - // Avoid leaving interactive popups open while input is blocked. - if !enabled && !matches!(self.active_popup, ActivePopup::None) { - self.active_popup = ActivePopup::None; - } - } - - pub fn set_task_running(&mut self, running: bool) { - self.is_task_running = running; - } - - pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens - { - return; - } - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; - } - - pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { - self.esc_backtrack_hint = show; - if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - } -} - -impl Renderable for ChatComposer { - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if !self.input_enabled { - return None; - } - - let [_, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } - - fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let footer_total_height = footer_hint_height + footer_spacing; - const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) - + 2 - + match &self.active_popup { - ActivePopup::None => footer_total_height, - ActivePopup::Command(c) => c.calculate_required_height(width), - ActivePopup::File(c) => c.calculate_required_height(), - ActivePopup::Skill(c) => c.calculate_required_height(width), - } - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - let [composer_rect, textarea_rect, popup_rect] = self.layout_areas(area); - match &self.active_popup { - ActivePopup::Command(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::File(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::Skill(popup) => { - popup.render_ref(popup_rect, buf); - } - ActivePopup::None => { - let footer_props = self.footer_props(); - let custom_height = self.custom_footer_height(); - let footer_hint_height = - custom_height.unwrap_or_else(|| footer_height(footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { - flash.line.render(inset_footer_hint_area(hint_rect), buf); - } - } else if let Some(items) = self.footer_hint_override.as_ref() { - render_footer_hint_items(hint_rect, buf, items); - } else { - render_footer(hint_rect, buf, footer_props); - } - } - } - let style = user_message_style(); - Block::default().style(style).render_ref(composer_rect, buf); - if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - "›".bold() - } else { - "›".dim() - }; - buf.set_span( - textarea_rect.x - LIVE_PREFIX_COLS, - textarea_rect.y, - &prompt, - textarea_rect.width, - ); - } - - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - let text = if self.input_enabled { - self.placeholder_text.as_str().to_string() - } else { - self.input_disabled_placeholder - .as_deref() - .unwrap_or("Input disabled.") - .to_string() - }; - let placeholder = Span::from(text).dim(); - Line::from(vec![placeholder]).render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); - } - } -} - -fn prompt_selection_action( - prompt: &CustomPrompt, - first_line: &str, - mode: PromptSelectionMode, -) -> PromptSelectionAction { - let named_args = prompt_argument_names(&prompt.content); - let has_numeric = prompt_has_numeric_placeholders(&prompt.content); - - match mode { - PromptSelectionMode::Completion => { - if !named_args.is_empty() { - let (text, cursor) = - prompt_command_with_arg_placeholders(&prompt.name, &named_args); - return PromptSelectionAction::Insert { - text, - cursor: Some(cursor), - }; - } - if has_numeric { - let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); - return PromptSelectionAction::Insert { text, cursor: None }; - } - let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name); - PromptSelectionAction::Insert { text, cursor: None } - } - PromptSelectionMode::Submit => { - if !named_args.is_empty() { - let (text, cursor) = - prompt_command_with_arg_placeholders(&prompt.name, &named_args); - return PromptSelectionAction::Insert { - text, - cursor: Some(cursor), - }; - } - if has_numeric { - if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { - return PromptSelectionAction::Submit { text: expanded }; - } - let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); - return PromptSelectionAction::Insert { text, cursor: None }; - } - PromptSelectionAction::Submit { - text: prompt.content.clone(), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use image::ImageBuffer; - use image::Rgba; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - use tempfile::tempdir; - - use crate::app_event::AppEvent; - use crate::bottom_pane::AppEventSender; - use crate::bottom_pane::ChatComposer; - use crate::bottom_pane::InputResult; - use crate::bottom_pane::chat_composer::AttachedImage; - use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; - use crate::bottom_pane::textarea::TextArea; - use tokio::sync::mpsc::unbounded_channel; - - #[test] - fn footer_hint_row_is_separated_from_composer() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - - let row_to_string = |y: u16| { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - row - }; - - let mut hint_row: Option<(u16, String)> = None; - for y in 0..area.height { - let row = row_to_string(y); - if row.contains("? for shortcuts") { - hint_row = Some((y, row)); - break; - } - } - - let (hint_row_idx, hint_row_contents) = - hint_row.expect("expected footer hint row to be rendered"); - assert_eq!( - hint_row_idx, - area.height - 1, - "hint row should occupy the bottom line: {hint_row_contents:?}", - ); - - assert!( - hint_row_idx > 0, - "expected a spacing row above the footer hints", - ); - - let spacing_row = row_to_string(hint_row_idx - 1); - assert_eq!( - spacing_row.trim(), - "", - "expected blank spacing row above hints but saw: {spacing_row:?}", - ); - } - - #[test] - fn footer_flash_overrides_footer_hint_override() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); - composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - - let mut bottom_row = String::new(); - for x in 0..area.width { - bottom_row.push( - buf[(x, area.height - 1)] - .symbol() - .chars() - .next() - .unwrap_or(' '), - ); - } - assert!( - bottom_row.contains("FLASH"), - "expected flash content to render in footer row, saw: {bottom_row:?}", - ); - assert!( - !bottom_row.contains("K label"), - "expected flash to override hint override, saw: {bottom_row:?}", - ); - } - - #[test] - fn footer_flash_expires_and_falls_back_to_hint_override() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); - composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - composer.footer_flash.as_mut().unwrap().expires_at = - Instant::now() - Duration::from_secs(1); - - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - - let mut bottom_row = String::new(); - for x in 0..area.width { - bottom_row.push( - buf[(x, area.height - 1)] - .symbol() - .chars() - .next() - .unwrap_or(' '), - ); - } - assert!( - bottom_row.contains("K label"), - "expected hint override to render after flash expired, saw: {bottom_row:?}", - ); - assert!( - !bottom_row.contains("FLASH"), - "expected expired flash to be hidden, saw: {bottom_row:?}", - ); - } - - fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) - where - F: FnOnce(&mut ChatComposer), - { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let width = 100; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - enhanced_keys_supported, - "Ask Codex to do anything".to_string(), - false, - ); - setup(&mut composer); - let footer_props = composer.footer_props(); - let footer_lines = footer_height(footer_props); - let footer_spacing = ChatComposer::footer_spacing(footer_lines); - let height = footer_lines + footer_spacing + 8; - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap(); - insta::assert_snapshot!(name, terminal.backend()); - } - - #[test] - fn footer_mode_snapshots() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { - composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { - composer.set_task_running(true); - composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { - composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state( - "footer_mode_overlay_then_external_esc_hint", - true, - |composer| { - let _ = composer - .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - composer.set_esc_backtrack_hint(true); - }, - ); - - snapshot_composer_state("footer_mode_hidden_while_typing", true, |composer| { - type_chars_humanlike(composer, &['h']); - }); - } - - #[test] - fn esc_hint_stays_hidden_with_draft_content() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - true, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike(&mut composer, &['d']); - - assert!(!composer.is_empty()); - assert_eq!(composer.current_text(), "d"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert!(matches!(composer.active_popup, ActivePopup::None)); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert!(!composer.esc_backtrack_hint); - } - - #[test] - fn clear_for_ctrl_c_records_cleared_draft() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_text_content("draft text".to_string()); - assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); - assert!(composer.is_empty()); - - assert_eq!( - composer.history.navigate_up(&composer.app_event_tx), - Some("draft text".to_string()) - ); - } - - /// Behavior: `?` toggles the shortcut overlay only when the composer is otherwise empty. After - /// any typing has occurred, `?` should be inserted as a literal character. - #[test] - fn question_mark_only_toggles_on_first_char() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let (result, needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); - - // Toggle back to prompt mode so subsequent typing captures characters. - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - - type_chars_humanlike(&mut composer, &['h']); - assert_eq!(composer.textarea.text(), "h"); - assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); - - let (result, needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - assert!(needs_redraw, "typing should still mark the view dirty"); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); - } - - /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut - /// overlay; it should be treated as part of the pasted content. - #[test] - fn question_mark_does_not_toggle_during_paste_burst() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Force an active paste burst so this test doesn't depend on tight timing. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); - - let flushed = flush_after_paste_burst(&mut composer); - assert!(flushed, "expected buffered text to flush after stop"); - - assert_eq!(composer.textarea.text(), "hi?there"); - assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); - } - - #[test] - fn shortcut_overlay_persists_while_task_running() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); - - composer.set_task_running(true); - - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); - assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); - } - - #[test] - fn test_current_at_token_basic_cases() { - let test_cases = vec![ - // Valid @ tokens - ("@hello", 3, Some("hello".to_string()), "Basic ASCII token"), - ( - "@file.txt", - 4, - Some("file.txt".to_string()), - "ASCII with extension", - ), - ( - "hello @world test", - 8, - Some("world".to_string()), - "ASCII token in middle", - ), - ( - "@test123", - 5, - Some("test123".to_string()), - "ASCII with numbers", - ), - // Unicode examples - ("@İstanbul", 3, Some("İstanbul".to_string()), "Turkish text"), - ( - "@testЙЦУ.rs", - 8, - Some("testЙЦУ.rs".to_string()), - "Mixed ASCII and Cyrillic", - ), - ("@诶", 2, Some("诶".to_string()), "Chinese character"), - ("@👍", 2, Some("👍".to_string()), "Emoji token"), - // Invalid cases (should return None) - ("hello", 2, None, "No @ symbol"), - ( - "@", - 1, - Some("".to_string()), - "Only @ symbol triggers empty query", - ), - ("@ hello", 2, None, "@ followed by space"), - ("test @ world", 6, None, "@ with spaces around"), - ]; - - for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::new(); - textarea.insert_str(input); - textarea.set_cursor(cursor_pos); - - let result = ChatComposer::current_at_token(&textarea); - assert_eq!( - result, expected, - "Failed for case: {description} - input: '{input}', cursor: {cursor_pos}" - ); - } - } - - #[test] - fn test_current_at_token_cursor_positions() { - let test_cases = vec![ - // Different cursor positions within a token - ("@test", 0, Some("test".to_string()), "Cursor at @"), - ("@test", 1, Some("test".to_string()), "Cursor after @"), - ("@test", 5, Some("test".to_string()), "Cursor at end"), - // Multiple tokens - cursor determines which token - ("@file1 @file2", 0, Some("file1".to_string()), "First token"), - ( - "@file1 @file2", - 8, - Some("file2".to_string()), - "Second token", - ), - // Edge cases - ("@", 0, Some("".to_string()), "Only @ symbol"), - ("@a", 2, Some("a".to_string()), "Single character after @"), - ("", 0, None, "Empty input"), - ]; - - for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::new(); - textarea.insert_str(input); - textarea.set_cursor(cursor_pos); - - let result = ChatComposer::current_at_token(&textarea); - assert_eq!( - result, expected, - "Failed for cursor position case: {description} - input: '{input}', cursor: {cursor_pos}", - ); - } - } - - #[test] - fn test_current_at_token_whitespace_boundaries() { - let test_cases = vec![ - // Space boundaries - ( - "aaa@aaa", - 4, - None, - "Connected @ token - no completion by design", - ), - ( - "aaa @aaa", - 5, - Some("aaa".to_string()), - "@ token after space", - ), - ( - "test @file.txt", - 7, - Some("file.txt".to_string()), - "@ token after space", - ), - // Full-width space boundaries - ( - "test @İstanbul", - 8, - Some("İstanbul".to_string()), - "@ token after full-width space", - ), - ( - "@ЙЦУ @诶", - 10, - Some("诶".to_string()), - "Full-width space between Unicode tokens", - ), - // Tab and newline boundaries - ( - "test\t@file", - 6, - Some("file".to_string()), - "@ token after tab", - ), - ]; - - for (input, cursor_pos, expected, description) in test_cases { - let mut textarea = TextArea::new(); - textarea.insert_str(input); - textarea.set_cursor(cursor_pos); - - let result = ChatComposer::current_at_token(&textarea); - assert_eq!( - result, expected, - "Failed for whitespace boundary case: {description} - input: '{input}', cursor: {cursor_pos}", - ); - } - } - - /// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII - /// char arrives next, the pending ASCII char should still be preserved and the overall input - /// should submit normally (i.e. we should not misclassify this as a paste burst). - #[test] - fn ascii_prefix_survives_non_ascii_followup() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - assert!(composer.is_in_paste_burst()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), - _ => panic!("expected Submitted"), - } - } - - /// Behavior: while we're capturing a paste-like burst, Enter should be treated as a newline - /// within the burst (not as "submit"), and the whole payload should flush as one paste. - #[test] - fn non_ascii_burst_buffers_enter_and_flushes_multiline() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('好'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你好\nhi"); - } - - /// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should - /// still be captured as a single paste payload and preserve the exact Unicode content. - #[test] - fn non_ascii_burst_preserves_ideographic_space_and_ascii() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - for ch in ['你', ' ', '好'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - for ch in ['h', 'i'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你 好\nhi"); - } - - /// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8", - /// "Unicode") should be captured as a single paste-like burst, and Enter key events should - /// become `\n` within the buffered content. - #[test] - fn non_ascii_burst_buffers_large_multiline_mixed_ascii_and_unicode() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - const LARGE_MIXED_PAYLOAD: &str = "天地玄黄 宇宙洪荒\n\ -日月盈昃 辰宿列张\n\ -寒来暑往 秋收冬藏\n\ -\n\ -你好世界 编码测试\n\ -汉字处理 UTF-8\n\ -终端显示 正确无误\n\ -\n\ -风吹竹林 月照大江\n\ -白云千载 青山依旧\n\ -程序员 与 Unicode 同行"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Force an active burst so the test doesn't depend on timing heuristics. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - for ch in LARGE_MIXED_PAYLOAD.chars() { - let code = if ch == '\n' { - KeyCode::Enter - } else { - KeyCode::Char(ch) - }; - let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); - } - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD); - } - - /// Behavior: a single non-ASCII char should be inserted immediately (IME-friendly) and should - /// not create any paste-burst state. - #[test] - fn non_ascii_char_inserts_immediately_without_burst_state() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - assert_eq!(composer.textarea.text(), "あ"); - assert!(!composer.is_in_paste_burst()); - } - - /// Behavior: a single non-ASCII char should submit normally on Enter (i.e. no burst/newline - /// suppression for the "IME single character" case). - #[test] - fn enter_submits_after_single_non_ascii_char() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "あ"), - _ => panic!("expected Submitted"), - } - } - - /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a - /// newline into the buffered payload and flush as a single paste later. - #[test] - fn ascii_burst_treats_enter_as_newline() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Force an active burst so this test doesn't depend on tight timing. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!( - matches!(result, InputResult::None), - "Enter during a burst should insert newline, not submit" - ); - - for ch in ['t', 'h', 'e', 'r', 'e'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "hi\nthere"); - } - - /// Behavior: even if Enter suppression would normally be active for a burst, Enter should - /// still dispatch a built-in slash command when the first line begins with `/`. - #[test] - fn slash_context_enter_ignores_paste_burst_enter_suppression() { - use crate::slash_command::SlashCommand; - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.textarea.set_text("/diff"); - composer.textarea.set_cursor("/diff".len()); - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!(result, InputResult::Command(SlashCommand::Diff))); - } - - /// Behavior: if a burst is buffering text and the user presses a non-char key, flush the - /// buffered burst *before* applying that key so the buffer cannot get stuck. - #[test] - fn non_char_key_flushes_active_burst_before_input() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Force an active burst so we can deterministically buffer characters without relying on - // timing. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); - assert!(composer.is_in_paste_burst()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "hi"); - assert_eq!(composer.textarea.cursor(), 1); - assert!(!composer.is_in_paste_burst()); - } - - /// Behavior: enabling `disable_paste_burst` flushes any held first character (flicker - /// suppression) and then inserts subsequent chars immediately without creating burst state. - #[test] - fn disable_paste_burst_flushes_pending_first_char_and_inserts_immediately() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // First ASCII char is normally held briefly. Flip the config mid-stream and ensure the - // held char is not dropped. - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); - assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); - - composer.set_disable_paste_burst(true); - assert_eq!(composer.textarea.text(), "a"); - assert!(!composer.is_in_paste_burst()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "ab"); - assert!(!composer.is_in_paste_burst()); - } - - /// Behavior: when a burst is already active, a non-ASCII char should be captured into the - /// burst buffer via the `try_append_char_if_active` fast-path. - #[test] - fn non_ascii_appends_to_active_burst_buffer() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Force an active burst so the non-ASCII char takes the fast-path - // (try_append_char_if_active) into the burst buffer. - composer - .paste_burst - .begin_with_retro_grabbed(String::new(), Instant::now()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - - assert!(composer.textarea.text().is_empty()); - let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "1あ"); - } - - /// Behavior: a small explicit paste inserts text directly (no placeholder), and the submitted - /// text matches what is visible in the textarea. - #[test] - fn handle_paste_small_inserts_text() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let needs_redraw = composer.handle_paste("hello".to_string()); - assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "hello"); - assert!(composer.pending_pastes.is_empty()); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), - _ => panic!("expected Submitted"), - } - } - - #[test] - fn empty_enter_returns_none() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // 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:?}"), - } - } - - /// Behavior: a large explicit paste inserts a placeholder into the textarea, stores the full - /// content in `pending_pastes`, and expands the placeholder to the full content on submit. - #[test] - fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); - let needs_redraw = composer.handle_paste(large.clone()); - assert!(needs_redraw); - let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large); - - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, large), - _ => panic!("expected Submitted"), - } - assert!(composer.pending_pastes.is_empty()); - } - - /// Behavior: editing that removes a paste placeholder should also clear the associated - /// `pending_pastes` entry so it cannot be submitted accidentally. - #[test] - fn edit_clears_pending_paste() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.handle_paste(large); - assert_eq!(composer.pending_pastes.len(), 1); - - // Any edit that removes the placeholder should clear pending_paste - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn ui_snapshots() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { - Ok(t) => t, - Err(e) => panic!("Failed to create terminal: {e}"), - }; - - let test_cases = vec![ - ("empty", None), - ("small", Some("short".to_string())), - ("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))), - ("multiple_pastes", None), - ("backspace_after_pastes", None), - ]; - - for (name, input) in test_cases { - // Create a fresh composer for each test case - let mut composer = ChatComposer::new( - true, - sender.clone(), - false, - "Ask Codex to do anything".to_string(), - false, - ); - - if let Some(text) = input { - composer.handle_paste(text); - } else if name == "multiple_pastes" { - // First large paste - composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3)); - // Second large paste - composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7)); - // Small paste - composer.handle_paste(" another short paste".to_string()); - } else if name == "backspace_after_pastes" { - // Three large pastes - composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2)); - composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); - composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); - // Move cursor to end and press backspace - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - } - - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}")); - - insta::assert_snapshot!(name, terminal.backend()); - } - } - - #[test] - fn slash_popup_model_first_for_mo_ui() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Type "/mo" humanlike so paste-burst doesn’t interfere. - type_chars_humanlike(&mut composer, &['/', 'm', 'o']); - - let mut terminal = match Terminal::new(TestBackend::new(60, 5)) { - Ok(t) => t, - Err(e) => panic!("Failed to create terminal: {e}"), - }; - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .unwrap_or_else(|e| panic!("Failed to draw composer: {e}")); - - // Visual snapshot should show the slash popup with /model as the first entry. - insta::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::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - 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] - fn slash_popup_resume_for_res_ui() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Type "/res" humanlike so paste-burst doesn’t interfere. - type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); - - let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); - terminal - .draw(|f| composer.render(f.area(), f.buffer_mut())) - .expect("draw composer"); - - // Snapshot should show /resume as the first entry for /res. - insta::assert_snapshot!("slash_popup_res", terminal.backend()); - } - - #[test] - fn slash_popup_resume_for_res_logic() { - use super::super::command_popup::CommandItem; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); - - match &composer.active_popup { - ActivePopup::Command(popup) => match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => { - assert_eq!(cmd.command(), "resume") - } - Some(CommandItem::UserPrompt(_)) => { - panic!("unexpected prompt selected for '/res'") - } - None => panic!("no selected command for '/res'"), - }, - _ => panic!("slash popup not active after typing '/res'"), - } - } - - // 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; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - for &ch in chars { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); - let _ = composer.flush_paste_burst_if_due(); - } - } - - fn flush_after_paste_burst(composer: &mut ChatComposer) -> bool { - std::thread::sleep(PasteBurst::recommended_active_flush_delay()); - composer.flush_paste_burst_if_due() - } - - #[test] - fn slash_init_dispatches_command_and_does_not_submit_literal_text() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Type the slash command. - type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']); - - // Press Enter to dispatch the selected command. - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // When a slash command is dispatched, the composer should return a - // Command result (not submit literal text) and clear its textarea. - match result { - InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "init"); - } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/init'") - } - InputResult::Submitted(text) => { - panic!("expected command dispatch, but composer submitted literal text: {text}") - } - InputResult::Queued(_) => { - panic!("expected command dispatch, but composer queued literal text") - } - InputResult::None => panic!("expected Command result for '/init'"), - } - assert!(composer.textarea.is_empty(), "composer should be cleared"); - } - - #[test] - fn extract_args_supports_quoted_paths_single_arg() { - let args = extract_positional_args_for_prompt_line( - "/prompts:review \"docs/My File.md\"", - "review", - ); - assert_eq!(args, vec!["docs/My File.md".to_string()]); - } - - #[test] - fn extract_args_supports_mixed_quoted_and_unquoted() { - let args = - extract_positional_args_for_prompt_line("/prompts:cmd \"with spaces\" simple", "cmd"); - assert_eq!(args, vec!["with spaces".to_string(), "simple".to_string()]); - } - - #[test] - fn slash_tab_completion_moves_cursor_to_end() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - 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, &['/', 'c']); - - let (_result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(composer.textarea.text(), "/compact "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); - } - - #[test] - fn slash_tab_then_enter_dispatches_builtin_command() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Type a prefix and complete with Tab, which inserts a trailing space - // and moves the cursor beyond the '/name' token (hides the popup). - type_chars_humanlike(&mut composer, &['/', 'd', 'i']); - let (_res, _redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/diff "); - - // Press Enter: should dispatch the command, not submit literal text. - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/diff'") - } - InputResult::Submitted(text) => { - panic!("expected command dispatch after Tab completion, got literal submit: {text}") - } - InputResult::Queued(_) => { - panic!("expected command dispatch after Tab completion, got literal queue") - } - InputResult::None => panic!("expected Command result for '/diff'"), - } - assert!(composer.textarea.is_empty()); - } - - #[test] - fn slash_mention_dispatches_command_and_inserts_at() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - 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', 'e', 'n', 't', 'i', 'o', 'n']); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - match result { - InputResult::Command(cmd) => { - assert_eq!(cmd.command(), "mention"); - } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/mention'") - } - InputResult::Submitted(text) => { - panic!("expected command dispatch, but composer submitted literal text: {text}") - } - InputResult::Queued(_) => { - panic!("expected command dispatch, but composer queued literal text") - } - InputResult::None => panic!("expected Command result for '/mention'"), - } - assert!(composer.textarea.is_empty(), "composer should be cleared"); - composer.insert_str("@"); - assert_eq!(composer.textarea.text(), "@"); - } - - /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their - /// original content on submission. - #[test] - fn test_multiple_pastes_submission() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Define test cases: (paste content, is_large) - let test_cases = [ - ("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true), - (" and ".to_string(), false), - ("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true), - ]; - - // Expected states after each paste - let mut expected_text = String::new(); - let mut expected_pending_count = 0; - - // Apply all pastes and build expected state - let states: Vec<_> = test_cases - .iter() - .map(|(content, is_large)| { - composer.handle_paste(content.clone()); - if *is_large { - let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); - expected_text.push_str(&placeholder); - expected_pending_count += 1; - } else { - expected_text.push_str(content); - } - (expected_text.clone(), expected_pending_count) - }) - .collect(); - - // Verify all intermediate states were correct - assert_eq!( - states, - vec![ - ( - format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()), - 1 - ), - ( - format!( - "[Pasted Content {} chars] and ", - test_cases[0].0.chars().count() - ), - 1 - ), - ( - format!( - "[Pasted Content {} chars] and [Pasted Content {} chars]", - test_cases[0].0.chars().count(), - test_cases[2].0.chars().count() - ), - 2 - ), - ] - ); - - // Submit and verify final expansion - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - if let InputResult::Submitted(text) = result { - assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0)); - } else { - panic!("expected Submitted"); - } - } - - #[test] - fn test_placeholder_deletion() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Define test cases: (content, is_large) - let test_cases = [ - ("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true), - (" and ".to_string(), false), - ("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true), - ]; - - // Apply all pastes - let mut current_pos = 0; - let states: Vec<_> = test_cases - .iter() - .map(|(content, is_large)| { - composer.handle_paste(content.clone()); - if *is_large { - let placeholder = format!("[Pasted Content {} chars]", content.chars().count()); - current_pos += placeholder.len(); - } else { - current_pos += content.len(); - } - ( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), - current_pos, - ) - }) - .collect(); - - // Delete placeholders one by one and collect states - let mut deletion_states = vec![]; - - // First deletion - composer.textarea.set_cursor(states[0].2); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), - )); - - // Second deletion - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), - )); - - // Verify all states - assert_eq!( - deletion_states, - vec![ - (" and [Pasted Content 1006 chars]".to_string(), 1), - (" and ".to_string(), 0), - ] - ); - } - - /// Behavior: if multiple large pastes share the same placeholder label (same char count), - /// deleting one placeholder removes only its corresponding `pending_pastes` entry. - #[test] - fn deleting_duplicate_length_pastes_removes_only_target() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let placeholder_base = format!("[Pasted Content {} chars]", paste.chars().count()); - let placeholder_second = format!("{placeholder_base} #2"); - - composer.handle_paste(paste.clone()); - composer.handle_paste(paste.clone()); - assert_eq!( - composer.textarea.text(), - format!("{placeholder_base}{placeholder_second}") - ); - assert_eq!(composer.pending_pastes.len(), 2); - - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - assert_eq!(composer.textarea.text(), placeholder_base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder_base); - assert_eq!(composer.pending_pastes[0].1, paste); - } - - /// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new - /// paste of the same length gets a new unique placeholder label. - #[test] - fn large_paste_numbering_does_not_reuse_after_deletion() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let base = format!("[Pasted Content {} chars]", paste.chars().count()); - let second = format!("{base} #2"); - let third = format!("{base} #3"); - - composer.handle_paste(paste.clone()); - composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), format!("{base}{second}")); - - composer.textarea.set_cursor(base.len()); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), second); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, second); - - composer.textarea.set_cursor(composer.textarea.text().len()); - composer.handle_paste(paste); - - assert_eq!(composer.textarea.text(), format!("{second}{third}")); - assert_eq!(composer.pending_pastes.len(), 2); - assert_eq!(composer.pending_pastes[0].0, second); - assert_eq!(composer.pending_pastes[1].0, third); - } - - #[test] - fn test_partial_placeholder_deletion() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Define test cases: (cursor_position_from_end, expected_pending_count) - let test_cases = [ - 5, // Delete from middle - should clear tracking - 0, // Delete from end - should clear tracking - ]; - - let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); - let placeholder = format!("[Pasted Content {} chars]", paste.chars().count()); - - let states: Vec<_> = test_cases - .into_iter() - .map(|pos_from_end| { - composer.handle_paste(paste.clone()); - composer - .textarea - .set_cursor(placeholder.len() - pos_from_end); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - let result = ( - composer.textarea.text().contains(&placeholder), - composer.pending_pastes.len(), - ); - composer.textarea.set_text(""); - result - }) - .collect(); - - assert_eq!( - states, - vec![ - (false, 0), // After deleting from middle - (false, 0), // After deleting from end - ] - ); - } - - // --- Image attachment tests --- - #[test] - fn attach_image_and_submit_includes_image_paths() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - let path = PathBuf::from("/tmp/image1.png"); - composer.attach_image(path.clone()); - composer.handle_paste(" hi".into()); - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(vec![path], imgs); - } - - #[test] - fn attach_image_without_text_submits_empty_text_and_images() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - let path = PathBuf::from("/tmp/image2.png"); - composer.attach_image(path.clone()); - let (result, _) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), - _ => panic!("expected Submitted"), - } - let imgs = composer.take_recent_submission_images(); - assert_eq!(imgs.len(), 1); - assert_eq!(imgs[0], path); - assert!(composer.attached_images.is_empty()); - } - - #[test] - fn image_placeholder_backspace_behaves_like_text_placeholder() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - let path = PathBuf::from("/tmp/image3.png"); - composer.attach_image(path.clone()); - let placeholder = composer.attached_images[0].placeholder.clone(); - - // Case 1: backspace at end - composer.textarea.move_cursor_to_end_of_line(false); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder)); - assert!(composer.attached_images.is_empty()); - - // Re-add and test backspace in middle: should break the placeholder string - // and drop the image mapping (same as text placeholder behavior). - composer.attach_image(path); - let placeholder2 = composer.attached_images[0].placeholder.clone(); - // Move cursor to roughly middle of placeholder - if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { - let mid_pos = start_pos + (placeholder2.len() / 2); - composer.textarea.set_cursor(mid_pos); - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder2)); - assert!(composer.attached_images.is_empty()); - } else { - panic!("Placeholder not found in textarea"); - } - } - - #[test] - fn backspace_with_multibyte_text_before_placeholder_does_not_panic() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Insert an image placeholder at the start - let path = PathBuf::from("/tmp/image_multibyte.png"); - composer.attach_image(path); - // Add multibyte text after the placeholder - composer.textarea.insert_str("日本語"); - - // Cursor is at end; pressing backspace should delete the last character - // without panicking and leave the placeholder intact. - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - assert_eq!(composer.attached_images.len(), 1); - assert!(composer.textarea.text().starts_with("[Image #1]")); - } - - #[test] - fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let path1 = PathBuf::from("/tmp/image_dup1.png"); - let path2 = PathBuf::from("/tmp/image_dup2.png"); - - composer.attach_image(path1); - // separate placeholders with a space for clarity - composer.handle_paste(" ".into()); - composer.attach_image(path2.clone()); - - let placeholder1 = composer.attached_images[0].placeholder.clone(); - let placeholder2 = composer.attached_images[1].placeholder.clone(); - let text = composer.textarea.text().to_string(); - let start1 = text.find(&placeholder1).expect("first placeholder present"); - let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); - - // Backspace should delete the first placeholder and its mapping. - composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - - let new_text = composer.textarea.text().to_string(); - assert_eq!( - 0, - new_text.matches(&placeholder2).count(), - "second placeholder was relabeled" - ); - assert_eq!( - 1, - new_text.matches("[Image #1]").count(), - "remaining placeholder relabeled to #1" - ); - assert_eq!( - vec![AttachedImage { - path: path2, - placeholder: "[Image #1]".to_string() - }], - composer.attached_images, - "one image mapping remains" - ); - } - - #[test] - fn deleting_first_text_element_renumbers_following_text_element() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let path1 = PathBuf::from("/tmp/image_first.png"); - let path2 = PathBuf::from("/tmp/image_second.png"); - - // Insert two adjacent atomic elements. - composer.attach_image(path1); - composer.attach_image(path2.clone()); - assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); - assert_eq!(composer.attached_images.len(), 2); - - // Delete the first element using normal textarea editing (Delete at cursor start). - composer.textarea.set_cursor(0); - composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); - - // Remaining image should be renumbered and the textarea element updated. - assert_eq!(composer.attached_images.len(), 1); - assert_eq!(composer.attached_images[0].path, path2); - assert_eq!(composer.attached_images[0].placeholder, "[Image #1]"); - assert_eq!(composer.textarea.text(), "[Image #1]"); - } - - #[test] - fn pasting_filepath_attaches_image() { - let tmp = tempdir().expect("create TempDir"); - let tmp_path: PathBuf = tmp.path().join("codex_tui_test_paste_image.png"); - let img: ImageBuffer, Vec> = - ImageBuffer::from_fn(3, 2, |_x, _y| Rgba([1, 2, 3, 255])); - img.save(&tmp_path).expect("failed to write temp png"); - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); - assert!(needs_redraw); - assert!(composer.textarea.text().starts_with("[Image #1] ")); - - let imgs = composer.take_recent_submission_images(); - assert_eq!(imgs, vec![tmp_path]); - } - - #[test] - fn selecting_custom_prompt_without_args_submits_content() { - let prompt_text = "Hello from saved prompt"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Inject prompts as if received via event. - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', - ], - ); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::Submitted(prompt_text.to_string()), result); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn custom_prompt_submission_expands_arguments() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - InputResult::Submitted("Review Alice changes on main".to_string()), - result - ); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn custom_prompt_submission_accepts_quoted_values() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Pair $USER with $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), - result - ); - assert!(composer.textarea.is_empty()); - } - - /// Behavior: selecting a custom prompt that includes a large paste placeholder should expand - /// to the full pasted content before submission. - #[test] - fn custom_prompt_with_large_paste_expands_correctly() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - // Create a custom prompt with positional args (no named args like $USER) - composer.set_custom_prompts(vec![CustomPrompt { - name: "code-review".to_string(), - path: "/tmp/code-review.md".to_string().into(), - content: "Please review the following code:\n\n$1".to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command - let command_text = "/prompts:code-review "; - composer.textarea.set_text(command_text); - composer.textarea.set_cursor(command_text.len()); - - // Paste large content (>3000 chars) to trigger placeholder - let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3000); - composer.handle_paste(large_content.clone()); - - // Verify placeholder was created - let placeholder = format!("[Pasted Content {} chars]", large_content.chars().count()); - assert_eq!( - composer.textarea.text(), - format!("/prompts:code-review {}", placeholder) - ); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large_content); - - // Submit by pressing Enter - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Verify the custom prompt was expanded with the large content as positional arg - match result { - InputResult::Submitted(text) => { - // The prompt should be expanded, with the large content replacing $1 - assert_eq!( - text, - format!("Please review the following code:\n\n{}", large_content), - "Expected prompt expansion with large content as $1" - ); - } - _ => panic!("expected Submitted, got: {result:?}"), - } - assert!(composer.textarea.is_empty()); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn slash_path_input_submits_without_command_error() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer - .textarea - .set_text("/Users/example/project/src/main.rs"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - if let InputResult::Submitted(text) = result { - assert_eq!(text, "/Users/example/project/src/main.rs"); - } else { - panic!("expected Submitted"); - } - assert!(composer.textarea.is_empty()); - match rx.try_recv() { - Ok(event) => panic!("unexpected event: {event:?}"), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} - Err(err) => panic!("unexpected channel state: {err:?}"), - } - } - - #[test] - fn slash_with_leading_space_submits_as_text() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.textarea.set_text(" /this-looks-like-a-command"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - if let InputResult::Submitted(text) = result { - assert_eq!(text, "/this-looks-like-a-command"); - } else { - panic!("expected Submitted"); - } - assert!(composer.textarea.is_empty()); - match rx.try_recv() { - Ok(event) => panic!("unexpected event: {event:?}"), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} - Err(err) => panic!("unexpected channel state: {err:?}"), - } - } - - #[test] - fn custom_prompt_invalid_args_reports_error() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes".to_string(), - description: None, - argument_hint: None, - }]); - - composer - .textarea - .set_text("/prompts:my-prompt USER=Alice stray"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::None, result); - assert_eq!( - "/prompts:my-prompt USER=Alice stray", - composer.textarea.text() - ); - - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.contains("expected key=value")); - found_error = true; - break; - } - } - assert!(found_error, "expected error history cell to be sent"); - } - - #[test] - fn custom_prompt_missing_required_args_reports_error() { - let (tx, mut rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]); - - // Provide only one of the required args - composer.textarea.set_text("/prompts:my-prompt USER=Alice"); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::None, result); - assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text()); - - let mut found_error = false; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - let message = cell - .display_lines(80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - assert!(message.to_lowercase().contains("missing required args")); - assert!(message.contains("BRANCH")); - found_error = true; - break; - } - } - assert!( - found_error, - "expected missing args error history cell to be sent" - ); - } - - #[test] - fn selecting_custom_prompt_with_args_expands_placeholders() { - // Support $1..$9 and $ARGUMENTS in prompt content. - let prompt_text = "Header: $1\nArgs: $ARGUMENTS\nNinth: $9\n"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - // Type the slash command with two args and hit Enter to submit. - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'm', 'y', '-', 'p', 'r', 'o', 'm', - 'p', 't', ' ', 'f', 'o', 'o', ' ', 'b', 'a', 'r', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let expected = "Header: foo\nArgs: foo bar\nNinth: \n".to_string(); - assert_eq!(InputResult::Submitted(expected), result); - } - - #[test] - fn numeric_prompt_positional_args_does_not_error() { - // Ensure that a prompt with only numeric placeholders does not trigger - // key=value parsing errors when given positional arguments. - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "elegant".to_string(), - path: "/tmp/elegant.md".to_string().into(), - content: "Echo: $ARGUMENTS".to_string(), - description: None, - argument_hint: None, - }]); - - // Type positional args; should submit with numeric expansion, no errors. - composer.textarea.set_text("/prompts:elegant hi"); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); - assert!(composer.textarea.is_empty()); - } - - #[test] - fn selecting_custom_prompt_with_no_args_inserts_template() { - let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "p".to_string(), - path: "/tmp/p.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &['/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p'], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // With no args typed, selecting the prompt inserts the command template - // and does not submit immediately. - assert_eq!(InputResult::None, result); - assert_eq!("/prompts:p ", composer.textarea.text()); - } - - #[test] - fn selecting_custom_prompt_preserves_literal_dollar_dollar() { - // '$$' should remain untouched. - let prompt_text = "Cost: $$ and first: $1"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "price".to_string(), - path: "/tmp/price.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'p', 'r', 'i', 'c', 'e', ' ', 'x', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - InputResult::Submitted("Cost: $$ and first: x".to_string()), - result - ); - } - - #[test] - fn selecting_custom_prompt_reuses_cached_arguments_join() { - let prompt_text = "First: $ARGUMENTS\nSecond: $ARGUMENTS"; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_steer_enabled(true); - - composer.set_custom_prompts(vec![CustomPrompt { - name: "repeat".to_string(), - path: "/tmp/repeat.md".to_string().into(), - content: prompt_text.to_string(), - description: None, - argument_hint: None, - }]); - - type_chars_humanlike( - &mut composer, - &[ - '/', 'p', 'r', 'o', 'm', 'p', 't', 's', ':', 'r', 'e', 'p', 'e', 'a', 't', ' ', - 'o', 'n', 'e', ' ', 't', 'w', 'o', - ], - ); - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - let expected = "First: one two\nSecond: one two".to_string(); - assert_eq!(InputResult::Submitted(expected), result); - } - - /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If - /// the payload is small, it should insert directly (no placeholder). - #[test] - fn burst_paste_fast_small_buffers_and_flushes_on_stop() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let count = 32; - for _ in 0..count { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); - assert!( - composer.is_in_paste_burst(), - "expected active paste burst during fast typing" - ); - assert!( - composer.textarea.text().is_empty(), - "text should not appear during burst" - ); - } - - assert!( - composer.textarea.text().is_empty(), - "text should remain empty until flush" - ); - let flushed = flush_after_paste_burst(&mut composer); - assert!(flushed, "expected buffered text to flush after stop"); - assert_eq!(composer.textarea.text(), "a".repeat(count)); - assert!( - composer.pending_pastes.is_empty(), - "no placeholder for small burst" - ); - } - - /// Behavior: fast "paste-like" ASCII input should buffer and then flush as a single paste. If - /// the payload is large, it should insert a placeholder and defer the full text until submit. - #[test] - fn burst_paste_fast_large_inserts_placeholder_on_flush() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder - for _ in 0..count { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - } - - // Nothing should appear until we stop and flush - assert!(composer.textarea.text().is_empty()); - let flushed = flush_after_paste_burst(&mut composer); - assert!(flushed, "expected flush after stopping fast input"); - - let expected_placeholder = format!("[Pasted Content {count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1.len(), count); - assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); - } - - /// Behavior: human-like typing (with delays between chars) should not be classified as a paste - /// burst. Characters should appear immediately and should not trigger a paste placeholder. - #[test] - fn humanlike_typing_1000_chars_appears_live_no_placeholder() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - let count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config - let chars: Vec = vec!['z'; count]; - type_chars_humanlike(&mut composer, &chars); - - assert_eq!(composer.textarea.text(), "z".repeat(count)); - assert!(composer.pending_pastes.is_empty()); - } - - #[test] - fn slash_popup_not_activated_for_slash_space_text_history_like_input() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use tokio::sync::mpsc::unbounded_channel; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Simulate history-like content: "/ test" - composer.set_text_content("/ test".to_string()); - - // After set_text_content -> sync_popups is called; popup should NOT be Command. - assert!( - matches!(composer.active_popup, ActivePopup::None), - "expected no slash popup for '/ test'" - ); - - // Up should be handled by history navigation path, not slash popup handler. - let (result, _redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - } - - #[test] - fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { - // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use tokio::sync::mpsc::unbounded_channel; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - // Case 1: bare "/" - composer.set_text_content("/".to_string()); - assert!( - matches!(composer.active_popup, ActivePopup::Command(_)), - "bare '/' should activate slash popup" - ); - - // Case 2: valid prefix "/re" (matches /review, /resume, etc.) - composer.set_text_content("/re".to_string()); - assert!( - matches!(composer.active_popup, ActivePopup::Command(_)), - "'/re' should activate slash popup via prefix match" - ); - - // Case 3: fuzzy match "/ac" (subsequence of /compact and /feedback) - composer.set_text_content("/ac".to_string()); - assert!( - matches!(composer.active_popup, ActivePopup::Command(_)), - "'/ac' should activate slash popup via fuzzy match" - ); - - // Case 4: invalid prefix "/zzz" – still allowed to open popup if it - // matches no built-in command; our current logic will not open popup. - // Verify that explicitly. - composer.set_text_content("/zzz".to_string()); - assert!( - matches!(composer.active_popup, ActivePopup::None), - "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" - ); - } - - #[test] - fn input_disabled_ignores_keypresses_and_hides_cursor() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_text_content("hello".to_string()); - composer.set_input_enabled(false, Some("Input disabled for test.".to_string())); - - let (result, needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - - assert_eq!(result, InputResult::None); - assert!(!needs_redraw); - assert_eq!(composer.current_text(), "hello"); - - let area = Rect { - x: 0, - y: 0, - width: 40, - height: 5, - }; - assert_eq!(composer.cursor_pos(area), None); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs deleted file mode 100644 index 991283a566..0000000000 --- a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs +++ /dev/null @@ -1,300 +0,0 @@ -use std::collections::HashMap; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use codex_core::protocol::Op; - -/// State machine that manages shell-style history navigation (Up/Down) inside -/// the chat composer. This struct is intentionally decoupled from the -/// rendering widget so the logic remains isolated and easier to test. -pub(crate) struct ChatComposerHistory { - /// Identifier of the history log as reported by `SessionConfiguredEvent`. - history_log_id: Option, - /// Number of entries already present in the persistent cross-session - /// history file when the session started. - history_entry_count: usize, - - /// Messages submitted by the user *during this UI session* (newest at END). - local_history: Vec, - - /// Cache of persistent history entries fetched on-demand. - fetched_history: HashMap, - - /// Current cursor within the combined (persistent + local) history. `None` - /// indicates the user is *not* currently browsing history. - history_cursor: Option, - - /// The text that was last inserted into the composer as a result of - /// history navigation. Used to decide if further Up/Down presses should be - /// treated as navigation versus normal cursor movement. - last_history_text: Option, -} - -impl ChatComposerHistory { - pub fn new() -> Self { - Self { - history_log_id: None, - history_entry_count: 0, - local_history: Vec::new(), - fetched_history: HashMap::new(), - history_cursor: None, - last_history_text: None, - } - } - - /// Update metadata when a new session is configured. - pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history_log_id = Some(log_id); - self.history_entry_count = entry_count; - self.fetched_history.clear(); - self.local_history.clear(); - self.history_cursor = None; - self.last_history_text = None; - } - - /// Record a message submitted by the user in the current session so it can - /// be recalled later. - pub fn record_local_submission(&mut self, text: &str) { - if text.is_empty() { - return; - } - - self.history_cursor = None; - self.last_history_text = None; - - // Avoid inserting a duplicate if identical to the previous entry. - if self.local_history.last().is_some_and(|prev| prev == text) { - return; - } - - self.local_history.push(text.to_string()); - } - - /// Reset navigation tracking so the next Up key resumes from the latest entry. - pub fn reset_navigation(&mut self) { - self.history_cursor = None; - self.last_history_text = None; - } - - /// Should Up/Down key presses be interpreted as history navigation given - /// the current content and cursor position of `textarea`? - pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { - if self.history_entry_count == 0 && self.local_history.is_empty() { - return false; - } - - if text.is_empty() { - return true; - } - - // Textarea is not empty – only navigate when cursor is at start and - // text matches last recalled history entry so regular editing is not - // hijacked. - if cursor != 0 { - return false; - } - - matches!(&self.last_history_text, Some(prev) if prev == text) - } - - /// Handle . Returns true when the key was consumed and the caller - /// should request a redraw. - pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { - let total_entries = self.history_entry_count + self.local_history.len(); - if total_entries == 0 { - return None; - } - - let next_idx = match self.history_cursor { - None => (total_entries as isize) - 1, - Some(0) => return None, // already at oldest - Some(idx) => idx - 1, - }; - - self.history_cursor = Some(next_idx); - self.populate_history_at_index(next_idx as usize, app_event_tx) - } - - /// Handle . - pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { - let total_entries = self.history_entry_count + self.local_history.len(); - if total_entries == 0 { - return None; - } - - let next_idx_opt = match self.history_cursor { - None => return None, // not browsing - Some(idx) if (idx as usize) + 1 >= total_entries => None, - Some(idx) => Some(idx + 1), - }; - - match next_idx_opt { - Some(idx) => { - self.history_cursor = Some(idx); - self.populate_history_at_index(idx as usize, app_event_tx) - } - None => { - // Past newest – clear and exit browsing mode. - self.history_cursor = None; - self.last_history_text = None; - Some(String::new()) - } - } - } - - /// Integrate a GetHistoryEntryResponse event. - pub fn on_entry_response( - &mut self, - log_id: u64, - offset: usize, - entry: Option, - ) -> Option { - if self.history_log_id != Some(log_id) { - return None; - } - let text = entry?; - self.fetched_history.insert(offset, text.clone()); - - if self.history_cursor == Some(offset as isize) { - self.last_history_text = Some(text.clone()); - return Some(text); - } - None - } - - // --------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------- - - fn populate_history_at_index( - &mut self, - global_idx: usize, - app_event_tx: &AppEventSender, - ) -> Option { - if global_idx >= self.history_entry_count { - // Local entry. - if let Some(text) = self - .local_history - .get(global_idx - self.history_entry_count) - { - self.last_history_text = Some(text.clone()); - return Some(text.clone()); - } - } else if let Some(text) = self.fetched_history.get(&global_idx) { - self.last_history_text = Some(text.clone()); - return Some(text.clone()); - } else if let Some(log_id) = self.history_log_id { - let op = Op::GetHistoryEntryRequest { - offset: global_idx, - log_id, - }; - app_event_tx.send(AppEvent::CodexOp(op)); - } - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use codex_core::protocol::Op; - use tokio::sync::mpsc::unbounded_channel; - - #[test] - fn duplicate_submissions_are_not_recorded() { - let mut history = ChatComposerHistory::new(); - - // Empty submissions are ignored. - history.record_local_submission(""); - assert_eq!(history.local_history.len(), 0); - - // First entry is recorded. - history.record_local_submission("hello"); - assert_eq!(history.local_history.len(), 1); - assert_eq!(history.local_history.last().unwrap(), "hello"); - - // Identical consecutive entry is skipped. - history.record_local_submission("hello"); - assert_eq!(history.local_history.len(), 1); - - // Different entry is recorded. - history.record_local_submission("world"); - assert_eq!(history.local_history.len(), 2); - assert_eq!(history.local_history.last().unwrap(), "world"); - } - - #[test] - fn navigation_with_async_fetch() { - let (tx, mut rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - - let mut history = ChatComposerHistory::new(); - // Pretend there are 3 persistent entries. - history.set_metadata(1, 3); - - // First Up should request offset 2 (latest) and await async data. - assert!(history.should_handle_navigation("", 0)); - assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet - - // Verify that an AppEvent::CodexOp with the correct GetHistoryEntryRequest was sent. - let event = rx.try_recv().expect("expected AppEvent to be sent"); - let AppEvent::CodexOp(history_request1) = event else { - panic!("unexpected event variant"); - }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2 - }, - history_request1 - ); - - // Inject the async response. - assert_eq!( - Some("latest".into()), - history.on_entry_response(1, 2, Some("latest".into())) - ); - - // Next Up should move to offset 1. - assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet - - // Verify second CodexOp event for offset 1. - let event2 = rx.try_recv().expect("expected second event"); - let AppEvent::CodexOp(history_request_2) = event2 else { - panic!("unexpected event variant"); - }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1 - }, - history_request_2 - ); - - assert_eq!( - Some("older".into()), - history.on_entry_response(1, 1, Some("older".into())) - ); - } - - #[test] - fn reset_navigation_resets_cursor() { - let (tx, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx); - - let mut history = ChatComposerHistory::new(); - history.set_metadata(1, 3); - history.fetched_history.insert(1, "command2".into()); - history.fetched_history.insert(2, "command3".into()); - - assert_eq!(Some("command3".into()), history.navigate_up(&tx)); - assert_eq!(Some("command2".into()), history.navigate_up(&tx)); - - history.reset_navigation(); - assert!(history.history_cursor.is_none()); - assert!(history.last_history_text.is_none()); - - assert_eq!(Some("command3".into()), history.navigate_up(&tx)); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs deleted file mode 100644 index 6da8c4f3a8..0000000000 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ /dev/null @@ -1,452 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::render_rows; -use crate::render::Insets; -use crate::render::RectExt; -use crate::slash_command::SlashCommand; -use crate::slash_command::built_in_slash_commands; -use codex_common::fuzzy_match::fuzzy_match; -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use std::collections::HashSet; - -fn windows_degraded_sandbox_active() -> bool { - cfg!(target_os = "windows") - && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled() -} - -/// A selectable item in the popup: either a built-in command or a user prompt. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum CommandItem { - Builtin(SlashCommand), - // Index into `prompts` - UserPrompt(usize), -} - -pub(crate) struct CommandPopup { - command_filter: String, - builtins: Vec<(&'static str, SlashCommand)>, - prompts: Vec, - state: ScrollState, -} - -#[derive(Clone, Copy, Debug, Default)] -pub(crate) struct CommandPopupFlags { - pub(crate) skills_enabled: bool, - pub(crate) collaboration_modes_enabled: bool, - pub(crate) connectors_enabled: bool, -} - -impl CommandPopup { - pub(crate) fn new(mut prompts: Vec, flags: CommandPopupFlags) -> Self { - let allow_elevate_sandbox = windows_degraded_sandbox_active(); - let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) - .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) - .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) - .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Connectors) - .collect(); - // Exclude prompts that collide with builtin command names and sort by name. - let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); - prompts.retain(|p| !exclude.contains(&p.name)); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - Self { - command_filter: String::new(), - builtins, - prompts, - state: ScrollState::new(), - } - } - - pub(crate) fn set_prompts(&mut self, mut prompts: Vec) { - let exclude: HashSet = self - .builtins - .iter() - .map(|(n, _)| (*n).to_string()) - .collect(); - prompts.retain(|p| !exclude.contains(&p.name)); - prompts.sort_by(|a, b| a.name.cmp(&b.name)); - self.prompts = prompts; - } - - pub(crate) fn prompt(&self, idx: usize) -> Option<&CustomPrompt> { - self.prompts.get(idx) - } - - /// Update the filter string based on the current composer text. The text - /// passed in is expected to start with a leading '/'. Everything after the - /// *first* '/" on the *first* line becomes the active filter that is used - /// to narrow down the list of available commands. - pub(crate) fn on_composer_text_change(&mut self, text: String) { - let first_line = text.lines().next().unwrap_or(""); - - if let Some(stripped) = first_line.strip_prefix('/') { - // Extract the *first* token (sequence of non-whitespace - // characters) after the slash so that `/clear something` still - // shows the help for `/clear`. - let token = stripped.trim_start(); - let cmd_token = token.split_whitespace().next().unwrap_or(""); - - // Update the filter keeping the original case (commands are all - // lower-case for now but this may change in the future). - self.command_filter = cmd_token.to_string(); - } else { - // The composer no longer starts with '/'. Reset the filter so the - // popup shows the *full* command list if it is still displayed - // for some reason. - self.command_filter.clear(); - } - - // Reset or clamp selected index based on new filtered list. - let matches_len = self.filtered_items().len(); - self.state.clamp_selection(matches_len); - self.state - .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); - } - - /// Determine the preferred height of the popup for a given width. - /// Accounts for wrapped descriptions so that long tooltips don't overflow. - pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { - use super::selection_popup_common::measure_rows_height; - let rows = self.rows_from_matches(self.filtered()); - - measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) - } - - /// Compute fuzzy-filtered matches over built-in commands and user prompts, - /// paired with optional highlight indices and score. Preserves the original - /// presentation order for built-ins and prompts. - fn filtered(&self) -> Vec<(CommandItem, Option>, i32)> { - let filter = self.command_filter.trim(); - let mut out: Vec<(CommandItem, Option>, i32)> = Vec::new(); - if filter.is_empty() { - // Built-ins first, in presentation order. - for (_, cmd) in self.builtins.iter() { - out.push((CommandItem::Builtin(*cmd), None, 0)); - } - // Then prompts, already sorted by name. - for idx in 0..self.prompts.len() { - out.push((CommandItem::UserPrompt(idx), None, 0)); - } - return out; - } - - for (_, cmd) in self.builtins.iter() { - if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) { - out.push((CommandItem::Builtin(*cmd), Some(indices), score)); - } - } - // Support both search styles: - // - Typing "name" should surface "/prompts:name" results. - // - Typing "prompts:name" should also work. - for (idx, p) in self.prompts.iter().enumerate() { - let display = format!("{PROMPTS_CMD_PREFIX}:{}", p.name); - if let Some((indices, score)) = fuzzy_match(&display, filter) { - out.push((CommandItem::UserPrompt(idx), Some(indices), score)); - } - } - out - } - - fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(c, _, _)| c).collect() - } - - fn rows_from_matches( - &self, - matches: Vec<(CommandItem, Option>, i32)>, - ) -> Vec { - matches - .into_iter() - .map(|(item, indices, _)| { - let (name, description) = match item { - CommandItem::Builtin(cmd) => { - (format!("/{}", cmd.command()), cmd.description().to_string()) - } - CommandItem::UserPrompt(i) => { - let prompt = &self.prompts[i]; - let description = prompt - .description - .clone() - .unwrap_or_else(|| "send saved prompt".to_string()); - ( - format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), - description, - ) - } - }; - GenericDisplayRow { - name, - match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - display_shortcut: None, - description: Some(description), - wrap_indent: None, - } - }) - .collect() - } - - /// Move the selection cursor one step up. - pub(crate) fn move_up(&mut self) { - let len = self.filtered_items().len(); - self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - /// Move the selection cursor one step down. - pub(crate) fn move_down(&mut self) { - let matches_len = self.filtered_items().len(); - self.state.move_down_wrap(matches_len); - self.state - .ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len)); - } - - /// Return currently selected command, if any. - pub(crate) fn selected_item(&self) -> Option { - let matches = self.filtered_items(); - self.state - .selected_idx - .and_then(|idx| matches.get(idx).copied()) - } -} - -impl WidgetRef for CommandPopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let rows = self.rows_from_matches(self.filtered()); - render_rows( - area.inset(Insets::tlbr(0, 2, 0, 0)), - buf, - &rows, - &self.state, - MAX_POPUP_ROWS, - "no matches", - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - // Simulate the composer line starting with '/in' so the popup filters - // matching commands by prefix. - popup.on_composer_text_change("/in".to_string()); - - // Access the filtered list via the selected command and ensure that - // one of the matches is the new "init" command. - let matches = popup.filtered_items(); - let has_init = matches.iter().any(|item| match item { - CommandItem::Builtin(cmd) => cmd.command() == "init", - CommandItem::UserPrompt(_) => false, - }); - assert!( - has_init, - "expected '/init' to appear among filtered commands" - ); - } - - #[test] - fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/init".to_string()); - - // When an exact match exists, the selected command should be that - // command by default. - let selected = popup.selected_item(); - match selected { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "init"), - Some(CommandItem::UserPrompt(_)) => panic!("unexpected prompt selected for '/init'"), - None => panic!("expected a selected command for exact match"), - } - } - - #[test] - fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - 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 filtered_commands_keep_presentation_order() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/m".to_string()); - - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, - }) - .collect(); - assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]); - } - - #[test] - fn prompt_discovery_lists_custom_prompts() { - let prompts = vec![ - CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "hello from foo".to_string(), - description: None, - argument_hint: None, - }, - CustomPrompt { - name: "bar".to_string(), - path: "/tmp/bar.md".to_string().into(), - content: "hello from bar".to_string(), - description: None, - argument_hint: None, - }, - ]; - let popup = CommandPopup::new(prompts, CommandPopupFlags::default()); - let items = popup.filtered_items(); - let mut prompt_names: Vec = items - .into_iter() - .filter_map(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt(i).map(|p| p.name.clone()), - _ => None, - }) - .collect(); - prompt_names.sort(); - assert_eq!(prompt_names, vec!["bar".to_string(), "foo".to_string()]); - } - - #[test] - fn prompt_name_collision_with_builtin_is_ignored() { - // Create a prompt named like a builtin (e.g. "init"). - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "init".to_string(), - path: "/tmp/init.md".to_string().into(), - content: "should be ignored".to_string(), - description: None, - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let items = popup.filtered_items(); - let has_collision_prompt = items.into_iter().any(|it| match it { - CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), - _ => false, - }); - assert!( - !has_collision_prompt, - "prompt with builtin name should be ignored" - ); - } - - #[test] - fn prompt_description_uses_frontmatter_metadata() { - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "draftpr".to_string(), - path: "/tmp/draftpr.md".to_string().into(), - content: "body".to_string(), - description: Some("Create feature branch, commit and open draft PR.".to_string()), - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); - let description = rows.first().and_then(|row| row.description.as_deref()); - assert_eq!( - description, - Some("Create feature branch, commit and open draft PR.") - ); - } - - #[test] - fn prompt_description_falls_back_when_missing() { - let popup = CommandPopup::new( - vec![CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "body".to_string(), - description: None, - argument_hint: None, - }], - CommandPopupFlags::default(), - ); - let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); - let description = rows.first().and_then(|row| row.description.as_deref()); - assert_eq!(description, Some("send saved prompt")); - } - - #[test] - fn fuzzy_filter_matches_subsequence_for_ac() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/ac".to_string()); - - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, - }) - .collect(); - assert!( - cmds.contains(&"compact") && cmds.contains(&"feedback"), - "expected fuzzy search for '/ac' to include compact and feedback, got {cmds:?}" - ); - } - - #[test] - fn collab_command_hidden_when_collaboration_modes_disabled() { - let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default()); - popup.on_composer_text_change("/coll".to_string()); - - let cmds: Vec<&str> = popup - .filtered_items() - .into_iter() - .filter_map(|item| match item { - CommandItem::Builtin(cmd) => Some(cmd.command()), - CommandItem::UserPrompt(_) => None, - }) - .collect(); - assert!( - !cmds.contains(&"collab"), - "expected '/collab' to be hidden when collaboration modes are disabled, got {cmds:?}" - ); - } - - #[test] - fn collab_command_visible_when_collaboration_modes_enabled() { - let mut popup = CommandPopup::new( - Vec::new(), - CommandPopupFlags { - skills_enabled: false, - collaboration_modes_enabled: true, - connectors_enabled: false, - }, - ); - popup.on_composer_text_change("/collab".to_string()); - - match popup.selected_item() { - Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "collab"), - other => panic!("expected collab to be selected for exact match, got {other:?}"), - } - } -} diff --git a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs deleted file mode 100644 index e9f0ee697f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs +++ /dev/null @@ -1,247 +0,0 @@ -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::Widget; -use std::cell::RefCell; - -use crate::render::renderable::Renderable; - -use super::popup_consts::standard_popup_hint_line; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::textarea::TextArea; -use super::textarea::TextAreaState; - -/// Callback invoked when the user submits a custom prompt. -pub(crate) type PromptSubmitted = Box; - -/// Minimal multi-line text input view to collect custom review instructions. -pub(crate) struct CustomPromptView { - title: String, - placeholder: String, - context_label: Option, - on_submit: PromptSubmitted, - - // UI state - textarea: TextArea, - textarea_state: RefCell, - complete: bool, -} - -impl CustomPromptView { - pub(crate) fn new( - title: String, - placeholder: String, - context_label: Option, - on_submit: PromptSubmitted, - ) -> Self { - Self { - title, - placeholder, - context_label, - on_submit, - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - complete: false, - } - } -} - -impl BottomPaneView for CustomPromptView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { - (self.on_submit)(text); - self.complete = true; - } - } - KeyEvent { - code: KeyCode::Enter, - .. - } => { - self.textarea.input(key_event); - } - other => { - self.textarea.input(other); - } - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn handle_paste(&mut self, pasted: String) -> bool { - if pasted.is_empty() { - return false; - } - self.textarea.insert_str(&pasted); - true - } -} - -impl Renderable for CustomPromptView { - fn desired_height(&self, width: u16) -> u16 { - let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; - 1u16 + extra_top + self.input_height(width) + 3u16 - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let input_height = self.input_height(area.width); - - // Title line - let title_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }; - let title_spans: Vec> = vec![gutter(), self.title.clone().bold()]; - Paragraph::new(Line::from(title_spans)).render(title_area, buf); - - // Optional context line - let mut input_y = area.y.saturating_add(1); - if let Some(context_label) = &self.context_label { - let context_area = Rect { - x: area.x, - y: input_y, - width: area.width, - height: 1, - }; - let spans: Vec> = vec![gutter(), context_label.clone().cyan()]; - Paragraph::new(Line::from(spans)).render(context_area, buf); - input_y = input_y.saturating_add(1); - } - - // Input line - let input_area = Rect { - x: area.x, - y: input_y, - width: area.width, - height: input_height, - }; - if input_area.width >= 2 { - for row in 0..input_area.height { - Paragraph::new(Line::from(vec![gutter()])).render( - Rect { - x: input_area.x, - y: input_area.y.saturating_add(row), - width: 2, - height: 1, - }, - buf, - ); - } - - let text_area_height = input_area.height.saturating_sub(1); - if text_area_height > 0 { - if input_area.width > 2 { - let blank_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y, - width: input_area.width.saturating_sub(2), - height: 1, - }; - Clear.render(blank_rect, buf); - } - let textarea_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y.saturating_add(1), - width: input_area.width.saturating_sub(2), - height: text_area_height, - }; - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - Paragraph::new(Line::from(self.placeholder.clone().dim())) - .render(textarea_rect, buf); - } - } - } - - let hint_blank_y = input_area.y.saturating_add(input_height); - if hint_blank_y < area.y.saturating_add(area.height) { - let blank_area = Rect { - x: area.x, - y: hint_blank_y, - width: area.width, - height: 1, - }; - Clear.render(blank_area, buf); - } - - let hint_y = hint_blank_y.saturating_add(1); - if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(standard_popup_hint_line()).render( - Rect { - x: area.x, - y: hint_y, - width: area.width, - height: 1, - }, - buf, - ); - } - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if area.height < 2 || area.width <= 2 { - return None; - } - let text_area_height = self.input_height(area.width).saturating_sub(1); - if text_area_height == 0 { - return None; - } - let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 }; - let top_line_count = 1u16 + extra_offset; - let textarea_rect = Rect { - x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } -} - -impl CustomPromptView { - fn input_height(&self, width: u16) -> u16 { - let usable_width = width.saturating_sub(2); - let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); - text_height.saturating_add(1).min(9) - } -} - -fn gutter() -> Span<'static> { - "▌ ".cyan() -} diff --git a/codex-rs/tui2/src/bottom_pane/feedback_view.rs b/codex-rs/tui2/src/bottom_pane/feedback_view.rs deleted file mode 100644 index 8fef8e79a1..0000000000 --- a/codex-rs/tui2/src/bottom_pane/feedback_view.rs +++ /dev/null @@ -1,574 +0,0 @@ -use std::cell::RefCell; -use std::path::PathBuf; - -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::Widget; - -use crate::app_event::AppEvent; -use crate::app_event::FeedbackCategory; -use crate::app_event_sender::AppEventSender; -use crate::history_cell; -use crate::render::renderable::Renderable; -use codex_core::protocol::SessionSource; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::popup_consts::standard_popup_hint_line; -use super::textarea::TextArea; -use super::textarea::TextAreaState; - -const BASE_BUG_ISSUE_URL: &str = - "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; - -/// Minimal input overlay to collect an optional feedback note, then upload -/// both logs and rollout with classification + metadata. -pub(crate) struct FeedbackNoteView { - category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, - rollout_path: Option, - app_event_tx: AppEventSender, - include_logs: bool, - - // UI state - textarea: TextArea, - textarea_state: RefCell, - complete: bool, -} - -impl FeedbackNoteView { - pub(crate) fn new( - category: FeedbackCategory, - snapshot: codex_feedback::CodexLogSnapshot, - rollout_path: Option, - app_event_tx: AppEventSender, - include_logs: bool, - ) -> Self { - Self { - category, - snapshot, - rollout_path, - app_event_tx, - include_logs, - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - complete: false, - } - } - - fn submit(&mut self) { - let note = self.textarea.text().trim().to_string(); - let reason_opt = if note.is_empty() { - None - } else { - Some(note.as_str()) - }; - let rollout_path_ref = self.rollout_path.as_deref(); - let classification = feedback_classification(self.category); - - let mut thread_id = self.snapshot.thread_id.clone(); - - let result = self.snapshot.upload_feedback( - classification, - reason_opt, - self.include_logs, - if self.include_logs { - rollout_path_ref - } else { - None - }, - Some(SessionSource::Cli), - ); - - match result { - Ok(()) => { - let prefix = if self.include_logs { - "• Feedback uploaded." - } else { - "• Feedback recorded (no logs)." - }; - let issue_url = issue_url_for_category(self.category, &thread_id); - let mut lines = vec![Line::from(match issue_url.as_ref() { - Some(_) => format!("{prefix} Please open an issue using the following URL:"), - None => format!("{prefix} Thanks for the feedback!"), - })]; - if let Some(url) = issue_url { - lines.extend([ - "".into(), - Line::from(vec![" ".into(), url.cyan().underlined()]), - "".into(), - Line::from(vec![ - " Or mention your thread ID ".into(), - std::mem::take(&mut thread_id).bold(), - " in an existing issue.".into(), - ]), - ]); - } else { - lines.extend([ - "".into(), - Line::from(vec![ - " Thread ID: ".into(), - std::mem::take(&mut thread_id).bold(), - ]), - ]); - } - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::PlainHistoryCell::new(lines), - ))); - } - Err(e) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(format!("Failed to upload feedback: {e}")), - ))); - } - } - self.complete = true; - } -} - -impl BottomPaneView for FeedbackNoteView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - self.submit(); - } - KeyEvent { - code: KeyCode::Enter, - .. - } => { - self.textarea.input(key_event); - } - other => { - self.textarea.input(other); - } - } - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn handle_paste(&mut self, pasted: String) -> bool { - if pasted.is_empty() { - return false; - } - self.textarea.insert_str(&pasted); - true - } -} - -impl Renderable for FeedbackNoteView { - fn desired_height(&self, width: u16) -> u16 { - 1u16 + self.input_height(width) + 3u16 - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if area.height < 2 || area.width <= 2 { - return None; - } - let text_area_height = self.input_height(area.width).saturating_sub(1); - if text_area_height == 0 { - return None; - } - let top_line_count = 1u16; // title only - let textarea_rect = Rect { - x: area.x.saturating_add(2), - y: area.y.saturating_add(top_line_count).saturating_add(1), - width: area.width.saturating_sub(2), - height: text_area_height, - }; - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let (title, placeholder) = feedback_title_and_placeholder(self.category); - let input_height = self.input_height(area.width); - - // Title line - let title_area = Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }; - let title_spans: Vec> = vec![gutter(), title.bold()]; - Paragraph::new(Line::from(title_spans)).render(title_area, buf); - - // Input line - let input_area = Rect { - x: area.x, - y: area.y.saturating_add(1), - width: area.width, - height: input_height, - }; - if input_area.width >= 2 { - for row in 0..input_area.height { - Paragraph::new(Line::from(vec![gutter()])).render( - Rect { - x: input_area.x, - y: input_area.y.saturating_add(row), - width: 2, - height: 1, - }, - buf, - ); - } - - let text_area_height = input_area.height.saturating_sub(1); - if text_area_height > 0 { - if input_area.width > 2 { - let blank_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y, - width: input_area.width.saturating_sub(2), - height: 1, - }; - Clear.render(blank_rect, buf); - } - let textarea_rect = Rect { - x: input_area.x.saturating_add(2), - y: input_area.y.saturating_add(1), - width: input_area.width.saturating_sub(2), - height: text_area_height, - }; - let mut state = self.textarea_state.borrow_mut(); - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); - if self.textarea.text().is_empty() { - Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf); - } - } - } - - let hint_blank_y = input_area.y.saturating_add(input_height); - if hint_blank_y < area.y.saturating_add(area.height) { - let blank_area = Rect { - x: area.x, - y: hint_blank_y, - width: area.width, - height: 1, - }; - Clear.render(blank_area, buf); - } - - let hint_y = hint_blank_y.saturating_add(1); - if hint_y < area.y.saturating_add(area.height) { - Paragraph::new(standard_popup_hint_line()).render( - Rect { - x: area.x, - y: hint_y, - width: area.width, - height: 1, - }, - buf, - ); - } - } -} - -impl FeedbackNoteView { - fn input_height(&self, width: u16) -> u16 { - let usable_width = width.saturating_sub(2); - let text_height = self.textarea.desired_height(usable_width).clamp(1, 8); - text_height.saturating_add(1).min(9) - } -} - -fn gutter() -> Span<'static> { - "▌ ".cyan() -} - -fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) { - match category { - FeedbackCategory::BadResult => ( - "Tell us more (bad result)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - FeedbackCategory::GoodResult => ( - "Tell us more (good result)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - FeedbackCategory::Bug => ( - "Tell us more (bug)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - FeedbackCategory::Other => ( - "Tell us more (other)".to_string(), - "(optional) Write a short description to help us further".to_string(), - ), - } -} - -fn feedback_classification(category: FeedbackCategory) -> &'static str { - match category { - FeedbackCategory::BadResult => "bad_result", - FeedbackCategory::GoodResult => "good_result", - FeedbackCategory::Bug => "bug", - FeedbackCategory::Other => "other", - } -} - -fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option { - match category { - FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some( - format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"), - ), - FeedbackCategory::GoodResult => None, - } -} - -// Build the selection popup params for feedback categories. -pub(crate) fn feedback_selection_params( - app_event_tx: AppEventSender, -) -> super::SelectionViewParams { - super::SelectionViewParams { - title: Some("How was this?".to_string()), - items: vec![ - make_feedback_item( - app_event_tx.clone(), - "bug", - "Crash, error message, hang, or broken UI/behavior.", - FeedbackCategory::Bug, - ), - make_feedback_item( - app_event_tx.clone(), - "bad result", - "Output was off-target, incorrect, incomplete, or unhelpful.", - FeedbackCategory::BadResult, - ), - make_feedback_item( - app_event_tx.clone(), - "good result", - "Helpful, correct, high‑quality, or delightful result worth celebrating.", - FeedbackCategory::GoodResult, - ), - make_feedback_item( - app_event_tx, - "other", - "Slowness, feature suggestion, UX feedback, or anything else.", - FeedbackCategory::Other, - ), - ], - ..Default::default() - } -} - -/// Build the selection popup params shown when feedback is disabled. -pub(crate) fn feedback_disabled_params() -> super::SelectionViewParams { - super::SelectionViewParams { - title: Some("Sending feedback is disabled".to_string()), - subtitle: Some("This action is disabled by configuration.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items: vec![super::SelectionItem { - name: "Close".to_string(), - dismiss_on_select: true, - ..Default::default() - }], - ..Default::default() - } -} - -fn make_feedback_item( - app_event_tx: AppEventSender, - name: &str, - description: &str, - category: FeedbackCategory, -) -> super::SelectionItem { - let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| { - app_event_tx.send(AppEvent::OpenFeedbackConsent { category }); - }); - super::SelectionItem { - name: name.to_string(), - description: Some(description.to_string()), - actions: vec![action], - dismiss_on_select: true, - ..Default::default() - } -} - -/// Build the upload consent popup params for a given feedback category. -pub(crate) fn feedback_upload_consent_params( - app_event_tx: AppEventSender, - category: FeedbackCategory, - rollout_path: Option, -) -> super::SelectionViewParams { - use super::popup_consts::standard_popup_hint_line; - let yes_action: super::SelectionAction = Box::new({ - let tx = app_event_tx.clone(); - move |sender: &AppEventSender| { - let _ = sender; - tx.send(AppEvent::OpenFeedbackNote { - category, - include_logs: true, - }); - } - }); - - let no_action: super::SelectionAction = Box::new({ - let tx = app_event_tx; - move |sender: &AppEventSender| { - let _ = sender; - tx.send(AppEvent::OpenFeedbackNote { - category, - include_logs: false, - }); - } - }); - - // Build header listing files that would be sent if user consents. - let mut header_lines: Vec> = vec![ - Line::from("Upload logs?".bold()).into(), - Line::from("").into(), - Line::from("The following files will be sent:".dim()).into(), - Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), - ]; - if let Some(path) = rollout_path.as_deref() - && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) - { - header_lines.push(Line::from(vec![" • ".into(), name.into()]).into()); - } - - super::SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items: vec![ - super::SelectionItem { - name: "Yes".to_string(), - description: Some( - "Share the current Codex session logs with the team for troubleshooting." - .to_string(), - ), - actions: vec![yes_action], - dismiss_on_select: true, - ..Default::default() - }, - super::SelectionItem { - name: "No".to_string(), - description: Some("".to_string()), - actions: vec![no_action], - dismiss_on_select: true, - ..Default::default() - }, - ], - header: Box::new(crate::render::renderable::ColumnRenderable::with( - header_lines, - )), - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::app_event_sender::AppEventSender; - - fn render(view: &FeedbackNoteView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - let mut lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line.trim_end().to_string() - }) - .collect(); - - while lines.first().is_some_and(|l| l.trim().is_empty()) { - lines.remove(0); - } - while lines.last().is_some_and(|l| l.trim().is_empty()) { - lines.pop(); - } - lines.join("\n") - } - - fn make_view(category: FeedbackCategory) -> FeedbackNoteView { - let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let snapshot = codex_feedback::CodexFeedback::new().snapshot(None); - FeedbackNoteView::new(category, snapshot, None, tx, true) - } - - #[test] - fn feedback_view_bad_result() { - let view = make_view(FeedbackCategory::BadResult); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_bad_result", rendered); - } - - #[test] - fn feedback_view_good_result() { - let view = make_view(FeedbackCategory::GoodResult); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_good_result", rendered); - } - - #[test] - fn feedback_view_bug() { - let view = make_view(FeedbackCategory::Bug); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_bug", rendered); - } - - #[test] - fn feedback_view_other() { - let view = make_view(FeedbackCategory::Other); - let rendered = render(&view, 60); - insta::assert_snapshot!("feedback_view_other", rendered); - } - - #[test] - fn issue_url_available_for_bug_bad_result_and_other() { - let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1"); - assert!( - bug_url - .as_deref() - .is_some_and(|url| url.contains("template=2-bug-report")) - ); - - let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2"); - assert!(bad_result_url.is_some()); - - let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3"); - assert!(other_url.is_some()); - - assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none()); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/file_search_popup.rs b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs deleted file mode 100644 index 064e4f0137..0000000000 --- a/codex-rs/tui2/src/bottom_pane/file_search_popup.rs +++ /dev/null @@ -1,154 +0,0 @@ -use codex_file_search::FileMatch; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use crate::render::Insets; -use crate::render::RectExt; - -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::render_rows; - -/// Visual state for the file-search popup. -pub(crate) struct FileSearchPopup { - /// Query corresponding to the `matches` currently shown. - display_query: String, - /// Latest query typed by the user. May differ from `display_query` when - /// a search is still in-flight. - pending_query: String, - /// When `true` we are still waiting for results for `pending_query`. - waiting: bool, - /// Cached matches; paths relative to the search dir. - matches: Vec, - /// Shared selection/scroll state. - state: ScrollState, -} - -impl FileSearchPopup { - pub(crate) fn new() -> Self { - Self { - display_query: String::new(), - pending_query: String::new(), - waiting: true, - matches: Vec::new(), - state: ScrollState::new(), - } - } - - /// Update the query and reset state to *waiting*. - pub(crate) fn set_query(&mut self, query: &str) { - if query == self.pending_query { - return; - } - - // Determine if current matches are still relevant. - let keep_existing = query.starts_with(&self.display_query); - - self.pending_query.clear(); - self.pending_query.push_str(query); - - self.waiting = true; // waiting for new results - - if !keep_existing { - self.matches.clear(); - self.state.reset(); - } - } - - /// Put the popup into an "idle" state used for an empty query (just "@"). - /// Shows a hint instead of matches until the user types more characters. - pub(crate) fn set_empty_prompt(&mut self) { - self.display_query.clear(); - self.pending_query.clear(); - self.waiting = false; - self.matches.clear(); - // Reset selection/scroll state when showing the empty prompt. - self.state.reset(); - } - - /// Replace matches when a `FileSearchResult` arrives. - /// Replace matches. Only applied when `query` matches `pending_query`. - pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { - if query != self.pending_query { - return; // stale - } - - self.display_query = query.to_string(); - self.matches = matches; - self.waiting = false; - let len = self.matches.len(); - self.state.clamp_selection(len); - self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); - } - - /// Move selection cursor up. - pub(crate) fn move_up(&mut self) { - let len = self.matches.len(); - self.state.move_up_wrap(len); - self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); - } - - /// Move selection cursor down. - pub(crate) fn move_down(&mut self) { - let len = self.matches.len(); - self.state.move_down_wrap(len); - self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS)); - } - - pub(crate) fn selected_match(&self) -> Option<&str> { - self.state - .selected_idx - .and_then(|idx| self.matches.get(idx)) - .map(|file_match| file_match.path.as_str()) - } - - pub(crate) fn calculate_required_height(&self) -> u16 { - // Row count depends on whether we already have matches. If no matches - // yet (e.g. initial search or query with no results) reserve a single - // row so the popup is still visible. When matches are present we show - // up to MAX_RESULTS regardless of the waiting flag so the list - // remains stable while a newer search is in-flight. - - self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16 - } -} - -impl WidgetRef for &FileSearchPopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. - let rows_all: Vec = if self.matches.is_empty() { - Vec::new() - } else { - self.matches - .iter() - .map(|m| GenericDisplayRow { - name: m.path.clone(), - match_indices: m - .indices - .as_ref() - .map(|v| v.iter().map(|&i| i as usize).collect()), - display_shortcut: None, - description: None, - wrap_indent: None, - }) - .collect() - }; - - let empty_message = if self.waiting { - "loading..." - } else { - "no matches" - }; - - render_rows( - area.inset(Insets::tlbr(0, 2, 0, 0)), - buf, - &rows_all, - &self.state, - MAX_POPUP_ROWS, - empty_message, - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs deleted file mode 100644 index fe5a13dc48..0000000000 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ /dev/null @@ -1,831 +0,0 @@ -//! The bottom-pane footer renders transient hints and context indicators. -//! -//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state. -//! It intentionally does not decide *which* footer content should be shown; that is owned by the -//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like -//! `ChatWidget` (which decides when quit/interrupt is allowed). -//! -//! Some footer content is time-based rather than event-based, such as the "press again to quit" -//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is -//! otherwise idle. -#[cfg(target_os = "linux")] -use crate::clipboard_paste::is_probably_wsl; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::render::line_utils::prefix_lines; -use crate::status::format_tokens_compact; -use crate::transcript_copy_action::TranscriptCopyFeedback; -use crate::ui_consts::FOOTER_INDENT_COLS; -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; - -/// The rendering inputs for the footer area under the composer. -/// -/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`, -/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as -/// authoritative and does not attempt to infer missing state (for example, it does not query -/// whether a task is running). -#[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps { - pub(crate) mode: FooterMode, - pub(crate) esc_backtrack_hint: bool, - pub(crate) use_shift_enter_hint: bool, - pub(crate) is_task_running: bool, - pub(crate) steer_enabled: bool, - pub(crate) collaboration_modes_enabled: bool, - /// Which key the user must press again to quit. - /// - /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. - pub(crate) quit_shortcut_key: KeyBinding, - pub(crate) context_window_percent: Option, - pub(crate) context_window_used_tokens: Option, - pub(crate) transcript_scrolled: bool, - pub(crate) transcript_selection_active: bool, - pub(crate) transcript_scroll_position: Option<(usize, usize)>, - pub(crate) transcript_copy_selection_key: KeyBinding, - pub(crate) transcript_copy_feedback: Option, -} - -/// Selects which footer content is rendered. -/// -/// The current mode is owned by `ChatComposer`, which may override it based on transient state -/// (for example, showing `QuitShortcutReminder` only while its timer is active). -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum FooterMode { - /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). - QuitShortcutReminder, - ShortcutSummary, - ShortcutOverlay, - EscHint, - ContextOnly, -} - -pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { - if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) { - return current; - } - - match current { - FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => { - FooterMode::ShortcutSummary - } - _ => FooterMode::ShortcutOverlay, - } -} - -pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { - if is_task_running { - current - } else { - FooterMode::EscHint - } -} - -pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { - match current { - FooterMode::EscHint - | FooterMode::ShortcutOverlay - | FooterMode::QuitShortcutReminder - | FooterMode::ContextOnly => FooterMode::ShortcutSummary, - other => other, - } -} - -pub(crate) fn footer_height(props: FooterProps) -> u16 { - footer_lines(props).len() as u16 -} - -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - Paragraph::new(prefix_lines( - footer_lines(props), - " ".repeat(FOOTER_INDENT_COLS).into(), - " ".repeat(FOOTER_INDENT_COLS).into(), - )) - .render(area, buf); -} - -pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { - if area.width > 2 { - area.x += 2; - area.width = area.width.saturating_sub(2); - } - area -} - -pub(crate) fn render_footer_hint_items(area: Rect, buf: &mut Buffer, items: &[(String, String)]) { - if items.is_empty() { - return; - } - - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(key.clone().bold()); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - Line::from(spans).render(inset_footer_hint_area(area), buf); -} - -fn footer_lines(props: FooterProps) -> Vec> { - fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) { - let Some(line) = lines.first_mut() else { - return; - }; - let Some(feedback) = feedback else { - return; - }; - - line.push_span(" · ".dim()); - match feedback { - TranscriptCopyFeedback::Copied => line.push_span("Copied".green().bold()), - TranscriptCopyFeedback::Failed => line.push_span("Copy failed".red().bold()), - } - } - - // Show the context indicator on the left, appended after the primary hint - // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when - // the shortcut hint is hidden). Hide it only for the multi-line - // ShortcutOverlay. - let mut lines = match props.mode { - FooterMode::QuitShortcutReminder => { - vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] - } - FooterMode::ShortcutSummary => { - let mut line = context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - ); - line.push_span(" · ".dim()); - line.extend(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ]); - if props.transcript_scrolled { - line.push_span(" · ".dim()); - line.push_span(key_hint::plain(KeyCode::PageUp)); - line.push_span("/"); - line.push_span(key_hint::plain(KeyCode::PageDown)); - line.push_span(" scroll".dim()); - line.push_span(" · ".dim()); - line.push_span(key_hint::plain(KeyCode::Home)); - line.push_span("/"); - line.push_span(key_hint::plain(KeyCode::End)); - line.push_span(" jump".dim()); - if let Some((current, total)) = props.transcript_scroll_position { - line.push_span(" · ".dim()); - line.push_span(Span::from(format!("{current}/{total}")).dim()); - } - } - if props.transcript_selection_active { - line.push_span(" · ".dim()); - line.push_span(props.transcript_copy_selection_key); - line.push_span(" copy selection".dim()); - } - vec![line] - } - FooterMode::ShortcutOverlay => { - #[cfg(target_os = "linux")] - let is_wsl = is_probably_wsl(); - #[cfg(not(target_os = "linux"))] - let is_wsl = false; - - let state = ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_wsl, - collaboration_modes_enabled: props.collaboration_modes_enabled, - }; - shortcut_overlay_lines(state) - } - FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => { - let mut line = context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - ); - if props.is_task_running && props.steer_enabled { - line.push_span(" · ".dim()); - line.push_span(key_hint::plain(KeyCode::Tab)); - line.push_span(" to queue message".dim()); - } - vec![line] - } - }; - apply_copy_feedback(&mut lines, props.transcript_copy_feedback); - lines -} - -#[derive(Clone, Copy, Debug)] -struct ShortcutsState { - use_shift_enter_hint: bool, - esc_backtrack_hint: bool, - is_wsl: bool, - collaboration_modes_enabled: bool, -} - -fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> { - Line::from(vec![key.into(), " again to quit".into()]).dim() -} - -fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { - let esc = key_hint::plain(KeyCode::Esc); - if esc_backtrack_hint { - Line::from(vec![esc.into(), " again to edit previous message".into()]).dim() - } else { - Line::from(vec![ - esc.into(), - " ".into(), - esc.into(), - " to edit previous message".into(), - ]) - .dim() - } -} - -fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut commands = Line::from(""); - let mut shell_commands = Line::from(""); - let mut newline = Line::from(""); - let mut queue_message_tab = Line::from(""); - let mut file_paths = Line::from(""); - let mut paste_image = Line::from(""); - let mut edit_previous = Line::from(""); - let mut quit = Line::from(""); - let mut show_transcript = Line::from(""); - let mut change_mode = Line::from(""); - - for descriptor in SHORTCUTS { - if let Some(text) = descriptor.overlay_entry(state) { - match descriptor.id { - ShortcutId::Commands => commands = text, - ShortcutId::ShellCommands => shell_commands = text, - ShortcutId::InsertNewline => newline = text, - ShortcutId::QueueMessageTab => queue_message_tab = text, - ShortcutId::FilePaths => file_paths = text, - ShortcutId::PasteImage => paste_image = text, - ShortcutId::EditPrevious => edit_previous = text, - ShortcutId::Quit => quit = text, - ShortcutId::ShowTranscript => show_transcript = text, - ShortcutId::ChangeMode => change_mode = text, - } - } - } - - let mut ordered = vec![ - commands, - shell_commands, - newline, - queue_message_tab, - file_paths, - paste_image, - edit_previous, - quit, - ]; - if change_mode.width() > 0 { - ordered.push(change_mode); - } - ordered.push(Line::from("")); - ordered.push(show_transcript); - - build_columns(ordered) -} - -fn build_columns(entries: Vec>) -> Vec> { - if entries.is_empty() { - return Vec::new(); - } - - const COLUMNS: usize = 2; - const COLUMN_PADDING: [usize; COLUMNS] = [4, 4]; - const COLUMN_GAP: usize = 4; - - let rows = entries.len().div_ceil(COLUMNS); - let target_len = rows * COLUMNS; - let mut entries = entries; - if entries.len() < target_len { - entries.extend(std::iter::repeat_n( - Line::from(""), - target_len - entries.len(), - )); - } - - let mut column_widths = [0usize; COLUMNS]; - - for (idx, entry) in entries.iter().enumerate() { - let column = idx % COLUMNS; - column_widths[column] = column_widths[column].max(entry.width()); - } - - for (idx, width) in column_widths.iter_mut().enumerate() { - *width += COLUMN_PADDING[idx]; - } - - entries - .chunks(COLUMNS) - .map(|chunk| { - let mut line = Line::from(""); - for (col, entry) in chunk.iter().enumerate() { - line.extend(entry.spans.clone()); - if col < COLUMNS - 1 { - let target_width = column_widths[col]; - let padding = target_width.saturating_sub(entry.width()) + COLUMN_GAP; - line.push_span(Span::from(" ".repeat(padding))); - } - } - line.dim() - }) - .collect() -} - -fn context_window_line(percent: Option, used_tokens: Option) -> Line<'static> { - if let Some(percent) = percent { - let percent = percent.clamp(0, 100); - return Line::from(vec![Span::from(format!("{percent}% context left")).dim()]); - } - - if let Some(tokens) = used_tokens { - let used_fmt = format_tokens_compact(tokens); - return Line::from(vec![Span::from(format!("{used_fmt} used")).dim()]); - } - - Line::from(vec![Span::from("100% context left").dim()]) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShortcutId { - Commands, - ShellCommands, - InsertNewline, - QueueMessageTab, - FilePaths, - PasteImage, - EditPrevious, - Quit, - ShowTranscript, - ChangeMode, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ShortcutBinding { - key: KeyBinding, - condition: DisplayCondition, -} - -impl ShortcutBinding { - fn matches(&self, state: ShortcutsState) -> bool { - self.condition.matches(state) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DisplayCondition { - Always, - WhenShiftEnterHint, - WhenNotShiftEnterHint, - WhenUnderWSL, - WhenCollaborationModesEnabled, -} - -impl DisplayCondition { - fn matches(self, state: ShortcutsState) -> bool { - match self { - DisplayCondition::Always => true, - DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, - DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, - DisplayCondition::WhenUnderWSL => state.is_wsl, - DisplayCondition::WhenCollaborationModesEnabled => state.collaboration_modes_enabled, - } - } -} - -struct ShortcutDescriptor { - id: ShortcutId, - bindings: &'static [ShortcutBinding], - prefix: &'static str, - label: &'static str, -} - -impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { - self.bindings.iter().find(|binding| binding.matches(state)) - } - - fn overlay_entry(&self, state: ShortcutsState) -> Option> { - let binding = self.binding_for(state)?; - let mut line = Line::from(vec![self.prefix.into(), binding.key.into()]); - match self.id { - ShortcutId::EditPrevious => { - if state.esc_backtrack_hint { - line.push_span(" again to edit previous message"); - } else { - line.extend(vec![ - " ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to edit previous message".into(), - ]); - } - } - _ => line.push_span(self.label), - }; - Some(line) - } -} - -const SHORTCUTS: &[ShortcutDescriptor] = &[ - ShortcutDescriptor { - id: ShortcutId::Commands, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('/')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for commands", - }, - ShortcutDescriptor { - id: ShortcutId::ShellCommands, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('!')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for shell commands", - }, - ShortcutDescriptor { - id: ShortcutId::InsertNewline, - bindings: &[ - ShortcutBinding { - key: key_hint::shift(KeyCode::Enter), - condition: DisplayCondition::WhenShiftEnterHint, - }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('j')), - condition: DisplayCondition::WhenNotShiftEnterHint, - }, - ], - prefix: "", - label: " for newline", - }, - ShortcutDescriptor { - id: ShortcutId::QueueMessageTab, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Tab), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to queue message", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Char('@')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, - ShortcutDescriptor { - id: ShortcutId::PasteImage, - // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain - // Ctrl+V); otherwise fall back to Ctrl+V. - bindings: &[ - ShortcutBinding { - key: key_hint::ctrl_alt(KeyCode::Char('v')), - condition: DisplayCondition::WhenUnderWSL, - }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('v')), - condition: DisplayCondition::Always, - }, - ], - prefix: "", - label: " to paste images", - }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - key: key_hint::plain(KeyCode::Esc), - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, - ShortcutDescriptor { - id: ShortcutId::Quit, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('c')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to exit", - }, - ShortcutDescriptor { - id: ShortcutId::ShowTranscript, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('t')), - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to view transcript", - }, - ShortcutDescriptor { - id: ShortcutId::ChangeMode, - bindings: &[ShortcutBinding { - key: key_hint::shift(KeyCode::Tab), - condition: DisplayCondition::WhenCollaborationModesEnabled, - }], - prefix: "", - label: " to change mode", - }, -]; - -#[cfg(test)] -mod tests { - use super::*; - use insta::assert_snapshot; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - fn snapshot_footer(name: &str, props: FooterProps) { - let height = footer_height(props).max(1); - let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); - terminal - .draw(|f| { - let area = Rect::new(0, 0, f.area().width, height); - render_footer(area, f.buffer_mut(), props); - }) - .unwrap(); - assert_snapshot!(name, terminal.backend()); - } - - #[test] - fn footer_snapshots() { - snapshot_footer( - "footer_shortcuts_default", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_transcript_scrolled_and_selection", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: true, - transcript_selection_active: true, - transcript_scroll_position: Some((3, 42)), - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_shift_and_esc", - FooterProps { - mode: FooterMode::ShortcutOverlay, - esc_backtrack_hint: true, - use_shift_enter_hint: true, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_collaboration_modes_enabled", - FooterProps { - mode: FooterMode::ShortcutOverlay, - esc_backtrack_hint: true, - use_shift_enter_hint: true, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: true, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_ctrl_c_quit_idle", - FooterProps { - mode: FooterMode::QuitShortcutReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_ctrl_c_quit_running", - FooterProps { - mode: FooterMode::QuitShortcutReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_esc_hint_idle", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_esc_hint_primed", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: true, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_shortcuts_context_running", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(72), - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_context_tokens_used", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: Some(123_456), - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_context_only_queue_hint_disabled", - FooterProps { - mode: FooterMode::ContextOnly, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_context_only_queue_hint_enabled", - FooterProps { - mode: FooterMode::ContextOnly, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: true, - steer_enabled: true, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: None, - }, - ); - - snapshot_footer( - "footer_copy_feedback_copied", - FooterProps { - mode: FooterMode::ShortcutSummary, - esc_backtrack_hint: false, - use_shift_enter_hint: false, - is_task_running: false, - steer_enabled: false, - collaboration_modes_enabled: false, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, - transcript_scrolled: false, - transcript_selection_active: false, - transcript_scroll_position: None, - transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), - transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied), - }, - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs deleted file mode 100644 index 27c7dc4233..0000000000 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ /dev/null @@ -1,873 +0,0 @@ -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use itertools::Itertools as _; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Block; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; - -use super::selection_popup_common::wrap_styled_line; -use crate::app_event_sender::AppEventSender; -use crate::key_hint::KeyBinding; -use crate::render::Insets; -use crate::render::RectExt as _; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::style::user_message_style; - -use super::CancellationEvent; -use super::bottom_pane_view::BottomPaneView; -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::measure_rows_height; -use super::selection_popup_common::render_rows; -use unicode_width::UnicodeWidthStr; - -/// One selectable item in the generic selection list. -pub(crate) type SelectionAction = Box; - -#[derive(Default)] -pub(crate) struct SelectionItem { - pub name: String, - pub display_shortcut: Option, - pub description: Option, - pub selected_description: Option, - pub is_current: bool, - pub is_default: bool, - pub actions: Vec, - pub dismiss_on_select: bool, - pub search_value: Option, -} - -pub(crate) struct SelectionViewParams { - pub title: Option, - pub subtitle: Option, - pub footer_note: Option>, - pub footer_hint: Option>, - pub items: Vec, - pub is_searchable: bool, - pub search_placeholder: Option, - pub header: Box, - pub initial_selected_idx: Option, -} - -impl Default for SelectionViewParams { - fn default() -> Self { - Self { - title: None, - subtitle: None, - footer_note: None, - footer_hint: None, - items: Vec::new(), - is_searchable: false, - search_placeholder: None, - header: Box::new(()), - initial_selected_idx: None, - } - } -} - -pub(crate) struct ListSelectionView { - footer_note: Option>, - footer_hint: Option>, - items: Vec, - state: ScrollState, - complete: bool, - app_event_tx: AppEventSender, - is_searchable: bool, - search_query: String, - search_placeholder: Option, - filtered_indices: Vec, - last_selected_actual_idx: Option, - header: Box, - initial_selected_idx: Option, -} - -impl ListSelectionView { - pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { - let mut header = params.header; - if params.title.is_some() || params.subtitle.is_some() { - let title = params.title.map(|title| Line::from(title.bold())); - let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); - header = Box::new(ColumnRenderable::with([ - header, - Box::new(title), - Box::new(subtitle), - ])); - } - let mut s = Self { - footer_note: params.footer_note, - footer_hint: params.footer_hint, - items: params.items, - state: ScrollState::new(), - complete: false, - app_event_tx, - is_searchable: params.is_searchable, - search_query: String::new(), - search_placeholder: if params.is_searchable { - params.search_placeholder - } else { - None - }, - filtered_indices: Vec::new(), - last_selected_actual_idx: None, - header, - initial_selected_idx: params.initial_selected_idx, - }; - s.apply_filter(); - s - } - - fn visible_len(&self) -> usize { - self.filtered_indices.len() - } - - fn max_visible_rows(len: usize) -> usize { - MAX_POPUP_ROWS.min(len.max(1)) - } - - fn apply_filter(&mut self) { - let previously_selected = self - .state - .selected_idx - .and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied()) - .or_else(|| { - (!self.is_searchable) - .then(|| self.items.iter().position(|item| item.is_current)) - .flatten() - }) - .or_else(|| self.initial_selected_idx.take()); - - if self.is_searchable && !self.search_query.is_empty() { - let query_lower = self.search_query.to_lowercase(); - self.filtered_indices = self - .items - .iter() - .positions(|item| { - item.search_value - .as_ref() - .is_some_and(|v| v.to_lowercase().contains(&query_lower)) - }) - .collect(); - } else { - self.filtered_indices = (0..self.items.len()).collect(); - } - - let len = self.filtered_indices.len(); - self.state.selected_idx = self - .state - .selected_idx - .and_then(|visible_idx| { - self.filtered_indices - .get(visible_idx) - .and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx)) - }) - .or_else(|| { - previously_selected.and_then(|actual_idx| { - self.filtered_indices - .iter() - .position(|idx| *idx == actual_idx) - }) - }) - .or_else(|| (len > 0).then_some(0)); - - let visible = Self::max_visible_rows(len); - self.state.clamp_selection(len); - self.state.ensure_visible(len, visible); - } - - fn build_rows(&self) -> Vec { - self.filtered_indices - .iter() - .enumerate() - .filter_map(|(visible_idx, actual_idx)| { - self.items.get(*actual_idx).map(|item| { - let is_selected = self.state.selected_idx == Some(visible_idx); - let prefix = if is_selected { '›' } else { ' ' }; - let name = item.name.as_str(); - let marker = if item.is_current { - " (current)" - } else if item.is_default { - " (default)" - } else { - "" - }; - let name_with_marker = format!("{name}{marker}"); - let n = visible_idx + 1; - let wrap_prefix = if self.is_searchable { - // The number keys don't work when search is enabled (since we let the - // numbers be used for the search query). - format!("{prefix} ") - } else { - format!("{prefix} {n}. ") - }; - let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); - let display_name = format!("{wrap_prefix}{name_with_marker}"); - let description = is_selected - .then(|| item.selected_description.clone()) - .flatten() - .or_else(|| item.description.clone()); - let wrap_indent = description.is_none().then_some(wrap_prefix_width); - GenericDisplayRow { - name: display_name, - display_shortcut: item.display_shortcut, - match_indices: None, - description, - wrap_indent, - } - }) - }) - .collect() - } - - fn move_up(&mut self) { - let len = self.visible_len(); - self.state.move_up_wrap(len); - let visible = Self::max_visible_rows(len); - self.state.ensure_visible(len, visible); - } - - fn move_down(&mut self) { - let len = self.visible_len(); - self.state.move_down_wrap(len); - let visible = Self::max_visible_rows(len); - self.state.ensure_visible(len, visible); - } - - fn accept(&mut self) { - if let Some(idx) = self.state.selected_idx - && let Some(actual_idx) = self.filtered_indices.get(idx) - && let Some(item) = self.items.get(*actual_idx) - { - self.last_selected_actual_idx = Some(*actual_idx); - for act in &item.actions { - act(&self.app_event_tx); - } - if item.dismiss_on_select { - self.complete = true; - } - } else { - self.complete = true; - } - } - - #[cfg(test)] - pub(crate) fn set_search_query(&mut self, query: String) { - self.search_query = query; - self.apply_filter(); - } - - pub(crate) fn take_last_selected_index(&mut self) -> Option { - self.last_selected_actual_idx.take() - } - - fn rows_width(total_width: u16) -> u16 { - total_width.saturating_sub(2) - } -} - -impl BottomPaneView for ListSelectionView { - fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle fallbacks for Ctrl-P/N here so navigation works everywhere. - KeyEvent { - code: KeyCode::Up, .. - } - | KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{0010}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^P */ => self.move_up(), - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::NONE, - .. - } if !self.is_searchable => self.move_up(), - KeyEvent { - code: KeyCode::Down, - .. - } - | KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Char('\u{000e}'), - modifiers: KeyModifiers::NONE, - .. - } /* ^N */ => self.move_down(), - KeyEvent { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::NONE, - .. - } if !self.is_searchable => self.move_down(), - KeyEvent { - code: KeyCode::Backspace, - .. - } if self.is_searchable => { - self.search_query.pop(); - self.apply_filter(); - } - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.on_ctrl_c(); - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if self.is_searchable - && !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.search_query.push(c); - self.apply_filter(); - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if !self.is_searchable - && !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - if let Some(idx) = c - .to_digit(10) - .map(|d| d as usize) - .and_then(|d| d.checked_sub(1)) - && idx < self.items.len() - { - self.state.selected_idx = Some(idx); - self.accept(); - } - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => self.accept(), - _ => {} - } - } - - fn is_complete(&self) -> bool { - self.complete - } - - fn on_ctrl_c(&mut self) -> CancellationEvent { - self.complete = true; - CancellationEvent::Handled - } -} - -impl Renderable for ListSelectionView { - fn desired_height(&self, width: u16) -> u16 { - // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. - // Build the same display rows used by the renderer so wrapping math matches. - let rows = self.build_rows(); - let rows_width = Self::rows_width(width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); - - // Subtract 4 for the padding on the left and right of the header. - let mut height = self.header.desired_height(width.saturating_sub(4)); - height = height.saturating_add(rows_height + 3); - if self.is_searchable { - height = height.saturating_add(1); - } - if let Some(note) = &self.footer_note { - let note_width = width.saturating_sub(2); - let note_lines = wrap_styled_line(note, note_width); - height = height.saturating_add(note_lines.len() as u16); - } - if self.footer_hint.is_some() { - height = height.saturating_add(1); - } - height - } - - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.height == 0 || area.width == 0 { - return; - } - - let note_width = area.width.saturating_sub(2); - let note_lines = self - .footer_note - .as_ref() - .map(|note| wrap_styled_line(note, note_width)); - let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); - let footer_rows = note_height + u16::from(self.footer_hint.is_some()); - let [content_area, footer_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); - - Block::default() - .style(user_message_style()) - .render(content_area, buf); - - let header_height = self - .header - // Subtract 4 for the padding on the left and right of the header. - .desired_height(content_area.width.saturating_sub(4)); - let rows = self.build_rows(); - let rows_width = Self::rows_width(content_area.width); - let rows_height = measure_rows_height( - &rows, - &self.state, - MAX_POPUP_ROWS, - rows_width.saturating_add(1), - ); - let [header_area, _, search_area, list_area] = Layout::vertical([ - Constraint::Max(header_height), - Constraint::Max(1), - Constraint::Length(if self.is_searchable { 1 } else { 0 }), - Constraint::Length(rows_height), - ]) - .areas(content_area.inset(Insets::vh(1, 2))); - - if header_area.height < header_height { - let [header_area, elision_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area); - self.header.render(header_area, buf); - Paragraph::new(vec![ - Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(), - ]) - .render(elision_area, buf); - } else { - self.header.render(header_area, buf); - } - - if self.is_searchable { - Line::from(self.search_query.clone()).render(search_area, buf); - let query_span: Span<'static> = if self.search_query.is_empty() { - self.search_placeholder - .as_ref() - .map(|placeholder| placeholder.clone().dim()) - .unwrap_or_else(|| "".into()) - } else { - self.search_query.clone().into() - }; - Line::from(query_span).render(search_area, buf); - } - - if list_area.height > 0 { - let render_area = Rect { - x: list_area.x.saturating_sub(2), - y: list_area.y, - width: rows_width.max(1), - height: list_area.height, - }; - render_rows( - render_area, - buf, - &rows, - &self.state, - render_area.height as usize, - "no matches", - ); - } - - if footer_area.height > 0 { - let [note_area, hint_area] = Layout::vertical([ - Constraint::Length(note_height), - Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), - ]) - .areas(footer_area); - - if let Some(lines) = note_lines { - let note_area = Rect { - x: note_area.x + 2, - y: note_area.y, - width: note_area.width.saturating_sub(2), - height: note_area.height, - }; - for (idx, line) in lines.iter().enumerate() { - if idx as u16 >= note_area.height { - break; - } - let line_area = Rect { - x: note_area.x, - y: note_area.y + idx as u16, - width: note_area.width, - height: 1, - }; - line.clone().render(line_area, buf); - } - } - - if let Some(hint) = &self.footer_hint { - let hint_area = Rect { - x: hint_area.x + 2, - y: hint_area.y, - width: hint_area.width.saturating_sub(2), - height: hint_area.height, - }; - hint.clone().dim().render(hint_area, buf); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::bottom_pane::popup_consts::standard_popup_hint_line; - use insta::assert_snapshot; - use ratatui::layout::Rect; - use tokio::sync::mpsc::unbounded_channel; - - fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Full Access".to_string(), - description: Some("Codex can edit files".to_string()), - is_current: false, - dismiss_on_select: true, - ..Default::default() - }, - ]; - ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - subtitle: subtitle.map(str::to_string), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }, - tx, - ) - } - - fn render_lines(view: &ListSelectionView) -> String { - render_lines_with_width(view, 48) - } - - fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String { - let height = view.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - view.render(area, &mut buf); - - let lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line - }) - .collect(); - lines.join("\n") - } - - #[test] - fn renders_blank_line_between_title_and_items_without_subtitle() { - let view = make_selection_view(None); - assert_snapshot!( - "list_selection_spacing_without_subtitle", - render_lines(&view) - ); - } - - #[test] - fn renders_blank_line_between_subtitle_and_items() { - let view = make_selection_view(Some("Switch between Codex approval presets")); - assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); - } - - #[test] - fn snapshot_footer_note_wraps() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }]; - let footer_note = Line::from(vec![ - "Note: ".dim(), - "Use /setup-elevated-sandbox".cyan(), - " to allow network access.".dim(), - ]); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_note: Some(footer_note), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_footer_note_wraps", - render_lines_with_width(&view, 40) - ); - } - - #[test] - fn renders_search_query_line_when_enabled() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: false, - dismiss_on_select: true, - ..Default::default() - }]; - let mut view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search branches".to_string()), - ..Default::default() - }, - tx, - ); - view.set_search_query("filters".to_string()); - - let lines = render_lines(&view); - assert!( - lines.contains("filters"), - "expected search query line to include rendered query, got {lines:?}" - ); - } - - #[test] - fn wraps_long_option_without_overflowing_columns() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "Yes, proceed".to_string(), - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Yes, and don't ask again for commands that start with `python -mpre_commit run --files eslint-plugin/no-mixed-const-enum-exports.js`".to_string(), - dismiss_on_select: true, - ..Default::default() - }, - ]; - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Approval".to_string()), - items, - ..Default::default() - }, - tx, - ); - - let rendered = render_lines_with_width(&view, 60); - let command_line = rendered - .lines() - .find(|line| line.contains("python -mpre_commit run")) - .expect("rendered lines should include wrapped command"); - assert!( - command_line.starts_with(" `python -mpre_commit run"), - "wrapped command line should align under the numbered prefix:\n{rendered}" - ); - assert!( - rendered.contains("eslint-plugin/no-") - && rendered.contains("mixed-const-enum-exports.js"), - "long command should not be truncated even when wrapped:\n{rendered}" - ); - } - - #[test] - fn width_changes_do_not_hide_rows() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "gpt-5.1-codex".to_string(), - description: Some( - "Optimized for Codex. Balance of reasoning quality and coding ability." - .to_string(), - ), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-5.1-codex-mini".to_string(), - description: Some( - "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-4.1-codex".to_string(), - description: Some( - "Legacy model. Use when you need compatibility with older automations." - .to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - ]; - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Model and Effort".to_string()), - items, - ..Default::default() - }, - tx, - ); - let mut missing: Vec = Vec::new(); - for width in 60..=90 { - let rendered = render_lines_with_width(&view, width); - if !rendered.contains("3.") { - missing.push(width); - } - } - assert!( - missing.is_empty(), - "third option missing at widths {missing:?}" - ); - } - - #[test] - fn narrow_width_keeps_all_rows_visible() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let desc = "x".repeat(10); - let items: Vec = (1..=3) - .map(|idx| SelectionItem { - name: format!("Item {idx}"), - description: Some(desc.clone()), - dismiss_on_select: true, - ..Default::default() - }) - .collect(); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Debug".to_string()), - items, - ..Default::default() - }, - tx, - ); - let rendered = render_lines_with_width(&view, 24); - assert!( - rendered.contains("3."), - "third option missing for width 24:\n{rendered}" - ); - } - - #[test] - fn snapshot_model_picker_width_80() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![ - SelectionItem { - name: "gpt-5.1-codex".to_string(), - description: Some( - "Optimized for Codex. Balance of reasoning quality and coding ability." - .to_string(), - ), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-5.1-codex-mini".to_string(), - description: Some( - "Optimized for Codex. Cheaper, faster, but less capable.".to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "gpt-4.1-codex".to_string(), - description: Some( - "Legacy model. Use when you need compatibility with older automations." - .to_string(), - ), - dismiss_on_select: true, - ..Default::default() - }, - ]; - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Model and Effort".to_string()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_model_picker_width_80", - render_lines_with_width(&view, 80) - ); - } - - #[test] - fn snapshot_narrow_width_preserves_third_option() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let desc = "x".repeat(10); - let items: Vec = (1..=3) - .map(|idx| SelectionItem { - name: format!("Item {idx}"), - description: Some(desc.clone()), - dismiss_on_select: true, - ..Default::default() - }) - .collect(); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Debug".to_string()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_narrow_width_preserves_rows", - render_lines_with_width(&view, 24) - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs deleted file mode 100644 index d5df125a47..0000000000 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ /dev/null @@ -1,959 +0,0 @@ -//! The bottom pane is the interactive footer of the chat UI. -//! -//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient -//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused -//! interactions like selection lists. -//! -//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs -//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent -//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active -//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may -//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit -//! shortcut. -//! -//! Some UI is time-based rather than input-based, such as the transient "press again to quit" -//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. -use std::path::PathBuf; - -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::queued_user_messages::QueuedUserMessages; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::render::renderable::FlexRenderable; -use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableItem; -use crate::tui::FrameRequester; -use bottom_pane_view::BottomPaneView; -use codex_core::features::Features; -use codex_core::skills::model::SkillMetadata; -use codex_file_search::FileMatch; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::text::Line; -use std::time::Duration; - -mod approval_overlay; -pub(crate) use approval_overlay::ApprovalOverlay; -pub(crate) use approval_overlay::ApprovalRequest; -mod bottom_pane_view; -mod chat_composer; -mod chat_composer_history; -mod command_popup; -pub mod custom_prompt_view; -mod file_search_popup; -mod footer; -mod list_selection_view; -mod prompt_args; -mod skill_popup; -pub(crate) use list_selection_view::SelectionViewParams; -mod feedback_view; -pub(crate) use feedback_view::feedback_disabled_params; -pub(crate) use feedback_view::feedback_selection_params; -pub(crate) use feedback_view::feedback_upload_consent_params; -mod paste_burst; -pub mod popup_consts; -mod queued_user_messages; -mod scroll_state; -mod selection_popup_common; -mod textarea; -pub(crate) use feedback_view::FeedbackNoteView; - -/// How long the "press again to quit" hint stays visible. -/// -/// This is shared between: -/// - `ChatWidget`: arming the double-press quit shortcut. -/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint. -/// -/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically. -pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1); - -/// Whether Ctrl+C/Ctrl+D require a second press to quit. -/// -/// This UX experiment was enabled by default, but requiring a double press to quit feels janky in -/// practice (especially for users accustomed to shells and other TUIs). Disable it for now while we -/// rethink a better quit/interrupt design. -pub(crate) const DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED: bool = false; - -/// The result of offering a cancellation key to a bottom-pane surface. -/// -/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss -/// themselves, and the caller can decide what higher-level action (if any) to take when the key is -/// not handled locally. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CancellationEvent { - Handled, - NotHandled, -} - -pub(crate) use chat_composer::ChatComposer; -pub(crate) use chat_composer::InputResult; -use codex_protocol::custom_prompts::CustomPrompt; - -use crate::status_indicator_widget::StatusIndicatorWidget; -pub(crate) use list_selection_view::SelectionAction; -pub(crate) use list_selection_view::SelectionItem; - -/// Pane displayed in the lower half of the chat UI. -/// -/// This is the owning container for the prompt input (`ChatComposer`) and the view stack -/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving -/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`. -pub(crate) struct BottomPane { - /// Composer is retained even when a BottomPaneView is displayed so the - /// input state is retained when the view is closed. - composer: ChatComposer, - - /// Stack of views displayed instead of the composer (e.g. popups/modals). - view_stack: Vec>, - - app_event_tx: AppEventSender, - frame_requester: FrameRequester, - - has_input_focus: bool, - is_task_running: bool, - esc_backtrack_hint: bool, - animations_enabled: bool, - - /// Inline status indicator shown above the composer while a task is running. - status: Option, - /// Queued user messages to show above the composer while a turn is running. - queued_user_messages: QueuedUserMessages, - context_window_percent: Option, - context_window_used_tokens: Option, -} - -pub(crate) struct BottomPaneParams { - pub(crate) app_event_tx: AppEventSender, - pub(crate) frame_requester: FrameRequester, - pub(crate) has_input_focus: bool, - pub(crate) enhanced_keys_supported: bool, - pub(crate) placeholder_text: String, - pub(crate) disable_paste_burst: bool, - pub(crate) animations_enabled: bool, - pub(crate) skills: Option>, -} - -impl BottomPane { - pub fn new(params: BottomPaneParams) -> Self { - let BottomPaneParams { - app_event_tx, - frame_requester, - has_input_focus, - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - animations_enabled, - skills, - } = params; - let mut composer = ChatComposer::new( - has_input_focus, - app_event_tx.clone(), - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - ); - composer.set_skill_mentions(skills); - - Self { - composer, - view_stack: Vec::new(), - app_event_tx, - frame_requester, - has_input_focus, - is_task_running: false, - status: None, - queued_user_messages: QueuedUserMessages::new(), - esc_backtrack_hint: false, - animations_enabled, - context_window_percent: None, - context_window_used_tokens: None, - } - } - - pub fn set_skills(&mut self, skills: Option>) { - self.composer.set_skill_mentions(skills); - self.request_redraw(); - } - - pub fn set_steer_enabled(&mut self, enabled: bool) { - self.composer.set_steer_enabled(enabled); - } - - pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) { - self.composer.set_collaboration_modes_enabled(enabled); - self.request_redraw(); - } - - pub fn set_connectors_enabled(&mut self, enabled: bool) { - self.composer.set_connectors_enabled(enabled); - self.request_redraw(); - } - - pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { - self.status.as_ref() - } - - pub fn skills(&self) -> Option<&Vec> { - self.composer.skills() - } - - #[cfg(test)] - pub(crate) fn context_window_percent(&self) -> Option { - self.context_window_percent - } - - #[cfg(test)] - pub(crate) fn context_window_used_tokens(&self) -> Option { - self.context_window_used_tokens - } - - fn active_view(&self) -> Option<&dyn BottomPaneView> { - self.view_stack.last().map(std::convert::AsRef::as_ref) - } - - fn push_view(&mut self, view: Box) { - self.view_stack.push(view); - self.request_redraw(); - } - - /// Forward a key event to the active view or the composer. - pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { - // If a modal/view is active, handle it here; otherwise forward to composer. - if let Some(view) = self.view_stack.last_mut() { - if key_event.code == KeyCode::Esc - && matches!(view.on_ctrl_c(), CancellationEvent::Handled) - && view.is_complete() - { - self.view_stack.pop(); - self.on_active_view_complete(); - } else { - view.handle_key_event(key_event); - if view.is_complete() { - self.view_stack.clear(); - self.on_active_view_complete(); - } - } - self.request_redraw(); - InputResult::None - } else { - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. - if matches!(key_event.code, crossterm::event::KeyCode::Esc) - && self.is_task_running - && let Some(status) = &self.status - { - // Send Op::Interrupt - status.interrupt(); - self.request_redraw(); - return InputResult::None; - } - let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); - if needs_redraw { - self.request_redraw(); - } - if self.composer.is_in_paste_burst() { - self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); - } - input_result - } - } - - /// Handles a Ctrl+C press within the bottom pane. - /// - /// An active modal view is given the first chance to consume the key (typically to dismiss - /// itself). If no view is active, Ctrl+C clears draft composer input. - /// - /// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C - /// was received, but it does not decide whether the process should exit; `ChatWidget` owns the - /// quit/interrupt state machine and uses the result to decide what happens next. - pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { - if let Some(view) = self.view_stack.last_mut() { - let event = view.on_ctrl_c(); - if matches!(event, CancellationEvent::Handled) { - if view.is_complete() { - self.view_stack.pop(); - self.on_active_view_complete(); - } - self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); - } - event - } else if self.composer_is_empty() { - CancellationEvent::NotHandled - } else { - self.view_stack.pop(); - self.clear_composer_for_ctrl_c(); - self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c'))); - CancellationEvent::Handled - } - } - - pub fn handle_paste(&mut self, pasted: String) { - if let Some(view) = self.view_stack.last_mut() { - let needs_redraw = view.handle_paste(pasted); - if view.is_complete() { - self.on_active_view_complete(); - } - if needs_redraw { - self.request_redraw(); - } - } else { - let needs_redraw = self.composer.handle_paste(pasted); - if needs_redraw { - self.request_redraw(); - } - } - } - - pub(crate) fn insert_str(&mut self, text: &str) { - self.composer.insert_str(text); - self.request_redraw(); - } - - /// Replace the composer text with `text`. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.composer.set_text_content(text); - self.request_redraw(); - } - - #[allow(dead_code)] - pub(crate) fn set_composer_input_enabled( - &mut self, - enabled: bool, - placeholder: Option, - ) { - self.composer.set_input_enabled(enabled, placeholder); - self.request_redraw(); - } - - pub(crate) fn clear_composer_for_ctrl_c(&mut self) { - self.composer.clear_for_ctrl_c(); - self.request_redraw(); - } - - /// Get the current composer text (for tests and programmatic checks). - pub(crate) fn composer_text(&self) -> String { - self.composer.current_text() - } - - /// Update the status indicator header (defaults to "Working") and details below it. - /// - /// Passing `None` clears any existing details. No-ops if the status indicator is not active. - pub(crate) fn update_status(&mut self, header: String, details: Option) { - if let Some(status) = self.status.as_mut() { - status.update_header(header); - status.update_details(details); - self.request_redraw(); - } - } - - /// Show the transient "press again to quit" hint for `key`. - /// - /// `ChatWidget` owns the quit shortcut state machine (it decides when quit is - /// allowed), while the bottom pane owns rendering. We also schedule a redraw - /// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user - /// stops typing and no other events trigger a draw. - pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) { - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - return; - } - - self.composer - .show_quit_shortcut_hint(key, self.has_input_focus); - let frame_requester = self.frame_requester.clone(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await; - frame_requester.schedule_frame(); - }); - } else { - // In tests (and other non-Tokio contexts), fall back to a thread so - // the hint can still expire without requiring an explicit draw. - std::thread::spawn(move || { - std::thread::sleep(QUIT_SHORTCUT_TIMEOUT); - frame_requester.schedule_frame(); - }); - } - self.request_redraw(); - } - - /// Clear the "press again to quit" hint immediately. - pub(crate) fn clear_quit_shortcut_hint(&mut self) { - self.composer.clear_quit_shortcut_hint(self.has_input_focus); - self.request_redraw(); - } - - #[cfg(test)] - pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.composer.quit_shortcut_hint_visible() - } - - #[cfg(test)] - pub(crate) fn status_indicator_visible(&self) -> bool { - self.status.is_some() - } - - pub(crate) fn show_esc_backtrack_hint(&mut self) { - self.esc_backtrack_hint = true; - self.composer.set_esc_backtrack_hint(true); - self.request_redraw(); - } - - pub(crate) fn clear_esc_backtrack_hint(&mut self) { - if self.esc_backtrack_hint { - self.esc_backtrack_hint = false; - self.composer.set_esc_backtrack_hint(false); - self.request_redraw(); - } - } - - // esc_backtrack_hint_visible removed; hints are controlled internally. - - pub fn set_task_running(&mut self, running: bool) { - let was_running = self.is_task_running; - self.is_task_running = running; - self.composer.set_task_running(running); - - if running { - if !was_running { - if self.status.is_none() { - self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - self.animations_enabled, - )); - } - if let Some(status) = self.status.as_mut() { - status.set_interrupt_hint_visible(true); - } - self.request_redraw(); - } - } else { - // Hide the status indicator when a task completes, but keep other modal views. - self.hide_status_indicator(); - } - } - - /// Hide the status indicator while leaving task-running state untouched. - pub(crate) fn hide_status_indicator(&mut self) { - if self.status.take().is_some() { - self.request_redraw(); - } - } - - pub(crate) fn ensure_status_indicator(&mut self) { - if self.status.is_none() { - self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - self.animations_enabled, - )); - self.request_redraw(); - } - } - - pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { - if let Some(status) = self.status.as_mut() { - status.set_interrupt_hint_visible(visible); - self.request_redraw(); - } - } - - pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens - { - return; - } - - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; - self.composer - .set_context_window(percent, self.context_window_used_tokens); - self.request_redraw(); - } - - pub(crate) fn set_transcript_ui_state( - &mut self, - scrolled: bool, - selection_active: bool, - scroll_position: Option<(usize, usize)>, - copy_selection_key: crate::key_hint::KeyBinding, - copy_feedback: Option, - ) { - let updated = self.composer.set_transcript_ui_state( - scrolled, - selection_active, - scroll_position, - copy_selection_key, - copy_feedback, - ); - if updated { - self.request_redraw(); - } - } - - /// Show a generic list selection view with the provided items. - pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { - let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); - self.push_view(Box::new(view)); - } - - /// Update the queued messages preview shown above the composer. - pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { - self.queued_user_messages.messages = queued; - self.request_redraw(); - } - - /// Update custom prompts available for the slash popup. - pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { - self.composer.set_custom_prompts(prompts); - self.request_redraw(); - } - - pub(crate) fn flash_footer_hint(&mut self, line: Line<'static>, duration: Duration) { - self.composer.show_footer_flash(line, duration); - let frame_requester = self.frame_requester.clone(); - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - tokio::time::sleep(duration).await; - frame_requester.schedule_frame(); - }); - } else { - std::thread::spawn(move || { - std::thread::sleep(duration); - frame_requester.schedule_frame(); - }); - } - self.request_redraw(); - } - - pub(crate) fn composer_is_empty(&self) -> bool { - self.composer.is_empty() - } - - pub(crate) fn is_task_running(&self) -> bool { - self.is_task_running - } - - /// Return true when the pane is in the regular composer state without any - /// overlays or popups and not running a task. This is the safe context to - /// use Esc-Esc for backtracking from the main view. - pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() - } - - /// Return true when no popups or modal views are active, regardless of task state. - pub(crate) fn can_launch_external_editor(&self) -> bool { - self.view_stack.is_empty() && !self.composer.popup_active() - } - - /// Returns true when the bottom pane has no active modal view and no active composer popup. - /// - /// This is the UI-level definition of "no modal/popup is active" for key routing decisions. - /// It intentionally does not include task state, since some actions are safe while a task is - /// running and some are not. - pub(crate) fn no_modal_or_popup_active(&self) -> bool { - self.can_launch_external_editor() - } - - pub(crate) fn show_view(&mut self, view: Box) { - self.push_view(view); - } - - /// Called when the agent requests user approval. - pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { - let request = if let Some(view) = self.view_stack.last_mut() { - match view.try_consume_approval_request(request) { - Some(request) => request, - None => { - self.request_redraw(); - return; - } - } - } else { - request - }; - - // Otherwise create a new approval modal overlay. - let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); - self.pause_status_timer_for_modal(); - self.push_view(Box::new(modal)); - } - - fn on_active_view_complete(&mut self) { - self.resume_status_timer_after_modal(); - } - - fn pause_status_timer_for_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.pause_timer(); - } - } - - fn resume_status_timer_after_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.resume_timer(); - } - } - - /// Height (terminal rows) required by the current bottom pane. - pub(crate) fn request_redraw(&self) { - self.frame_requester.schedule_frame(); - } - - pub(crate) fn request_redraw_in(&self, dur: Duration) { - self.frame_requester.schedule_frame_in(dur); - } - - // --- History helpers --- - - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.composer.set_history_metadata(log_id, entry_count); - } - - pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { - self.composer.flush_paste_burst_if_due() - } - - pub(crate) fn is_in_paste_burst(&self) -> bool { - self.composer.is_in_paste_burst() - } - - pub(crate) fn on_history_entry_response( - &mut self, - log_id: u64, - offset: usize, - entry: Option, - ) { - let updated = self - .composer - .on_history_entry_response(log_id, offset, entry); - - if updated { - self.request_redraw(); - } - } - - pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { - self.composer.on_file_search_result(query, matches); - self.request_redraw(); - } - - pub(crate) fn attach_image(&mut self, path: PathBuf) { - if self.view_stack.is_empty() { - self.composer.attach_image(path); - self.request_redraw(); - } - } - - pub(crate) fn take_recent_submission_images(&mut self) -> Vec { - self.composer.take_recent_submission_images() - } - - fn as_renderable(&'_ self) -> RenderableItem<'_> { - if let Some(view) = self.active_view() { - RenderableItem::Borrowed(view) - } else { - let mut flex = FlexRenderable::new(); - if let Some(status) = &self.status { - flex.push(0, RenderableItem::Borrowed(status)); - } - flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages)); - if self.status.is_some() || !self.queued_user_messages.messages.is_empty() { - flex.push(0, RenderableItem::Owned("".into())); - } - let mut flex2 = FlexRenderable::new(); - flex2.push(1, RenderableItem::Owned(flex.into())); - flex2.push(0, RenderableItem::Borrowed(&self.composer)); - RenderableItem::Owned(Box::new(flex2)) - } - } -} - -impl Renderable for BottomPane { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.as_renderable().render(area, buf); - } - fn desired_height(&self, width: u16) -> u16 { - self.as_renderable().desired_height(width) - } - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.as_renderable().cursor_pos(area) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use insta::assert_snapshot; - use ratatui::buffer::Buffer; - use ratatui::layout::Rect; - use tokio::sync::mpsc::unbounded_channel; - - fn snapshot_buffer(buf: &Buffer) -> String { - let mut lines = Vec::new(); - for y in 0..buf.area().height { - let mut row = String::new(); - for x in 0..buf.area().width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - lines.push(row); - } - lines.join("\n") - } - - fn render_snapshot(pane: &BottomPane, area: Rect) -> String { - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - snapshot_buffer(&buf) - } - - fn exec_request() -> ApprovalRequest { - ApprovalRequest::Exec { - id: "1".to_string(), - command: vec!["echo".into(), "ok".into()], - reason: None, - proposed_execpolicy_amendment: None, - } - } - - #[test] - fn ctrl_c_on_modal_consumes_without_showing_quit_hint() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let features = Features::with_defaults(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - pane.push_approval_request(exec_request(), &features); - assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); - assert!(!pane.quit_shortcut_hint_visible()); - assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); - } - - // live ring removed; related tests deleted. - - #[test] - fn overlay_not_shown_above_approval_modal() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let features = Features::with_defaults(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Create an approval modal (active view). - pane.push_approval_request(exec_request(), &features); - - // Render and verify the top row does not include an overlay. - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - - let mut r0 = String::new(); - for x in 0..area.width { - r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - !r0.contains("Working"), - "overlay should not render above modal" - ); - } - - #[test] - fn composer_shown_after_denied_while_task_running() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let features = Features::with_defaults(); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Start a running task so the status indicator is active above the composer. - pane.set_task_running(true); - - // Push an approval modal (e.g., command approval) which should hide the status view. - pane.push_approval_request(exec_request(), &features); - - // Simulate pressing 'n' (No) on the modal. - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - - // After denial, since the task is still running, the status indicator should be - // visible above the composer. The modal should be gone. - assert!( - pane.view_stack.is_empty(), - "no active modal view after denial" - ); - - // Render and ensure the top row includes the Working header and a composer line below. - // Give the animation thread a moment to tick. - std::thread::sleep(Duration::from_millis(120)); - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - let mut row0 = String::new(); - for x in 0..area.width { - row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row0.contains("Working"), - "expected Working header after denial on row 0: {row0:?}" - ); - - // Composer placeholder should be visible somewhere below. - let mut found_composer = false; - for y in 1..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("Ask Codex") { - found_composer = true; - break; - } - } - assert!( - found_composer, - "expected composer visible under status line" - ); - } - - #[test] - fn status_indicator_visible_during_command_execution() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Begin a task: show initial status. - pane.set_task_running(true); - - // Use a height that allows the status line to be visible above the composer. - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - pane.render(area, &mut buf); - - let bufs = snapshot_buffer(&buf); - assert!(bufs.contains("• Working"), "expected Working header"); - } - - #[test] - fn status_and_composer_fill_height_without_bottom_padding() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - // Activate spinner (status view replaces composer) with no live ring. - pane.set_task_running(true); - - // Use height == desired_height; expect spacer + status + composer rows without trailing padding. - let height = pane.desired_height(30); - assert!( - height >= 3, - "expected at least 3 rows to render spacer, status, and composer; got {height}" - ); - let area = Rect::new(0, 0, 30, height); - assert_snapshot!( - "status_and_composer_fill_height_without_bottom_padding", - render_snapshot(&pane, area) - ); - } - - #[test] - fn queued_messages_visible_when_status_hidden_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); - pane.hide_status_indicator(); - - let width = 48; - let height = pane.desired_height(width); - let area = Rect::new(0, 0, width, height); - assert_snapshot!( - "queued_messages_visible_when_status_hidden_snapshot", - render_snapshot(&pane, area) - ); - } - - #[test] - fn status_and_queued_messages_snapshot() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: true, - skills: Some(Vec::new()), - }); - - pane.set_task_running(true); - pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); - - let width = 48; - let height = pane.desired_height(width); - let area = Rect::new(0, 0, width, height); - assert_snapshot!( - "status_and_queued_messages_snapshot", - render_snapshot(&pane, area) - ); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs deleted file mode 100644 index 238c00d600..0000000000 --- a/codex-rs/tui2/src/bottom_pane/paste_burst.rs +++ /dev/null @@ -1,564 +0,0 @@ -//! Paste-burst detection for terminals without bracketed paste. -//! -//! On some platforms (notably Windows), pastes often arrive as a rapid stream of -//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event. -//! In that mode, the composer needs to: -//! -//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text. -//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message". -//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as -//! paste once enough chars have arrived. -//! -//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain" -//! character events (no Ctrl/Alt) and uses its decisions to either: -//! -//! - briefly hold a first ASCII char (flicker suppression), -//! - buffer a burst as a single pasted string, or -//! - let input flow through as normal typing. -//! -//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see -//! `docs/tui-chat-composer.md`. -//! -//! # Call Pattern -//! -//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds -//! it events and then applies the chosen action: -//! -//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or -//! [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME). -//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via -//! [`PasteBurst::append_char_to_buffer`]. -//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert -//! that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as -//! an explicit paste. -//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use -//! [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then -//! [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a -//! previous burst. -//! -//! # State Variables -//! -//! This state machine is encoded in a few fields with slightly different meanings: -//! -//! - `active`: true while we are still *actively* accepting characters into the current burst. -//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`. -//! A non-empty buffer is treated as "in burst context" even if `active` has been cleared. -//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must -//! not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or -//! flushes as a normal typed char (`FlushResult::Typed`). -//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for -//! "paste-like" streams. -//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the -//! buffer itself. -//! -//! # Timing Model -//! -//! There are two timeouts: -//! -//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be -//! considered part of a single burst. It also bounds how long `pending_first_char` is held. -//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last -//! char before flushing the accumulated buffer as a paste. -//! -//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI -//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`). -//! -//! # Retro Capture Details -//! -//! Retro-capture exists to handle the case where we initially inserted characters as "normal -//! typing", but later decide that the stream is paste-like. When that happens, we retroactively -//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so -//! the eventual `handle_paste(...)` sees a contiguous pasted string. -//! -//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME -//! input, and retro-grab scenarios). The ASCII path usually prefers -//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all. -//! -//! Retro-capture is expressed in terms of characters, not bytes: -//! -//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count. -//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by -//! calling `retro_start_index()`. -//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the -//! cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8. -//! -//! # Clearing vs Flushing -//! -//! There are two ways callers end burst handling, and they are not interchangeable: -//! -//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char) -//! so the caller can apply it through the normal paste path before handling an unrelated input. -//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does -//! not get grouped into the previous burst. It assumes the caller has already flushed any buffer -//! because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a -//! non-empty buffer until another plain char updates the timestamp. -//! -//! # States (Conceptually) -//! -//! - **Idle**: no buffered text, no pending char. -//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to -//! `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows. -//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes. -//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after -//! burst activity so multiline pastes stay grouped. -//! -//! # ASCII vs Non-ASCII -//! -//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first -//! ASCII char and avoid flicker. -//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since -//! holding a non-ASCII character can feel like dropped input. -//! -//! # Contract With `ChatComposer` -//! -//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must -//! interpret decisions and apply the corresponding UI edits: -//! -//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`]. -//! - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet. -//! - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the -//! current char (the previously-held char is already in the burst buffer). -//! - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted -//! prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the -//! returned `start_byte..cursor` range from the textarea and then call -//! [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back -//! to normal insertion. -//! - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`]. -//! -//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then: -//! - If it returns `Some(CharDecision::BufferAppend)`, call -//! [`PasteBurst::append_char_to_buffer`]. -//! - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call -//! [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed -//! prefix from the textarea and then append the current char to the buffer). -//! - If it returns `None`, insert normally. -//! -//! - Before applying non-char input (or any input that should not join a burst), call -//! [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the -//! normal paste path. -//! -//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`]. -//! - [`FlushResult::Typed`]: insert that single char as normal typing. -//! - [`FlushResult::Paste`]: treat the returned string as an explicit paste. -//! -//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use -//! [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being -//! incorrectly grouped into a previous burst. - -use std::time::Duration; -use std::time::Instant; - -// Heuristic thresholds for detecting paste-like input bursts. -// Detect quickly to avoid showing typed prefix before paste is recognized -const PASTE_BURST_MIN_CHARS: u16 = 3; -const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); -const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); -// Slower paste burts have been observed in windows environments, but ideally -// we want to keep this low -#[cfg(not(windows))] -const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8); -#[cfg(windows)] -const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60); - -#[derive(Default)] -pub(crate) struct PasteBurst { - last_plain_char_time: Option, - consecutive_plain_char_burst: u16, - burst_window_until: Option, - buffer: String, - active: bool, - // Hold first fast char briefly to avoid rendering flicker - pending_first_char: Option<(char, Instant)>, -} - -pub(crate) enum CharDecision { - /// Start buffering and retroactively capture some already-inserted chars. - BeginBuffer { retro_chars: u16 }, - /// We are currently buffering; append the current char into the buffer. - BufferAppend, - /// Do not insert/render this char yet; temporarily save the first fast - /// char while we wait to see if a paste-like burst follows. - RetainFirstChar, - /// Begin buffering using the previously saved first char (no retro grab needed). - BeginBufferFromPending, -} - -pub(crate) struct RetroGrab { - pub start_byte: usize, - 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 - /// out of the burst detector as normal typed input. - /// - /// Primarily used by tests and by the TUI to reliably cross the - /// paste-burst timing threshold. - pub fn recommended_flush_delay() -> Duration { - PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) - } - - #[cfg(test)] - pub(crate) fn recommended_active_flush_delay() -> Duration { - PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1) - } - - /// Entry point: decide how to treat a plain char with current timing. - pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { - self.note_plain_char(now); - - if self.active { - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - return CharDecision::BufferAppend; - } - - // If we already held a first char and receive a second fast char, - // start buffering without retro-grabbing (we never rendered the first). - if let Some((held, held_at)) = self.pending_first_char - && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL - { - self.active = true; - // take() to clear pending; we already captured the held char above - let _ = self.pending_first_char.take(); - self.buffer.push(held); - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - return CharDecision::BeginBufferFromPending; - } - - if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { - return CharDecision::BeginBuffer { - retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), - }; - } - - // Save the first fast char very briefly to see if a burst follows. - self.pending_first_char = Some((ch, now)); - CharDecision::RetainFirstChar - } - - /// Like on_plain_char(), but never holds the first char. - /// - /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can - /// feel like dropped input, while still allowing burst-based paste detection. - /// - /// Note: This method will only ever return BufferAppend or BeginBuffer. - pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option { - self.note_plain_char(now); - - if self.active { - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - return Some(CharDecision::BufferAppend); - } - - if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { - return Some(CharDecision::BeginBuffer { - retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), - }); - } - - None - } - - fn note_plain_char(&mut self, now: Instant) { - match self.last_plain_char_time { - Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { - self.consecutive_plain_char_burst = - self.consecutive_plain_char_burst.saturating_add(1) - } - _ => self.consecutive_plain_char_burst = 1, - } - self.last_plain_char_time = Some(now); - } - - /// Flushes any buffered burst if the inter-key timeout has elapsed. - /// - /// Returns: - /// - /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one - /// pasted string. - /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker - /// suppression) and no burst followed before the timeout elapsed. - /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush. - pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { - let timeout = if self.is_active_internal() { - PASTE_BURST_ACTIVE_IDLE_TIMEOUT - } else { - PASTE_BURST_CHAR_INTERVAL - }; - let timed_out = self - .last_plain_char_time - .is_some_and(|t| now.duration_since(t) > timeout); - if timed_out && self.is_active_internal() { - self.active = false; - let out = std::mem::take(&mut self.buffer); - 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() { - FlushResult::Typed(ch) - } else { - FlushResult::None - } - } else { - FlushResult::None - } - } - - /// While bursting: accumulate a newline into the buffer instead of - /// submitting the textarea. - /// - /// Returns true if a newline was appended (we are in a burst context), - /// false otherwise. - pub fn append_newline_if_active(&mut self, now: Instant) -> bool { - if self.is_active() { - self.buffer.push('\n'); - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - true - } else { - false - } - } - - /// Decide if Enter should insert a newline (burst context) vs submit. - pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { - let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); - self.is_active() || in_burst_window - } - - /// Keep the burst window alive. - pub fn extend_window(&mut self, now: Instant) { - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - } - - /// Begin buffering with retroactively grabbed text. - pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { - if !grabbed.is_empty() { - self.buffer.push_str(&grabbed); - } - self.active = true; - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - } - - /// Append a char into the burst buffer. - pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { - self.buffer.push(ch); - self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); - } - - /// Try to append a char into the burst buffer only if a burst is already active. - /// - /// Returns true when the char was captured into the existing burst, false otherwise. - pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool { - if self.active || !self.buffer.is_empty() { - self.append_char_to_buffer(ch, now); - true - } else { - false - } - } - - /// Decide whether to begin buffering by retroactively capturing recent - /// chars from the slice before the cursor. - /// - /// Heuristic: if the retro-grabbed slice contains any whitespace or is - /// sufficiently long (>= 16 characters), treat it as paste-like to avoid - /// rendering the typed prefix momentarily before the paste is recognized. - /// This favors responsiveness and prevents flicker for typical pastes - /// (URLs, file paths, multiline text) while not triggering on short words. - /// - /// Returns Some(RetroGrab) with the start byte and grabbed text when we - /// decide to buffer retroactively; otherwise None. - pub fn decide_begin_buffer( - &mut self, - now: Instant, - before: &str, - retro_chars: usize, - ) -> Option { - let start_byte = retro_start_index(before, retro_chars); - let grabbed = before[start_byte..].to_string(); - let looks_pastey = - grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16; - if looks_pastey { - // Note: caller is responsible for removing this slice from UI text. - self.begin_with_retro_grabbed(grabbed.clone(), now); - Some(RetroGrab { - start_byte, - grabbed, - }) - } else { - None - } - } - - /// Before applying modified/non-char input: flush buffered burst immediately. - pub fn flush_before_modified_input(&mut self) -> Option { - if !self.is_active() { - return None; - } - self.active = false; - let mut out = std::mem::take(&mut self.buffer); - if let Some((ch, _at)) = self.pending_first_char.take() { - out.push(ch); - } - Some(out) - } - - /// Clear only the timing window and any pending first-char. - /// - /// Does not emit or clear the buffered text itself; callers should have - /// already flushed (if needed) via one of the flush methods above. - pub fn clear_window_after_non_char(&mut self) { - self.consecutive_plain_char_burst = 0; - self.last_plain_char_time = None; - self.burst_window_until = None; - self.active = false; - self.pending_first_char = None; - } - - /// Returns true if we are in any paste-burst related transient state - /// (actively buffering, have a non-empty buffer, or have saved the first - /// fast char while waiting for a potential burst). - pub fn is_active(&self) -> bool { - self.is_active_internal() || self.pending_first_char.is_some() - } - - fn is_active_internal(&self) -> bool { - self.active || !self.buffer.is_empty() - } - - pub fn clear_after_explicit_paste(&mut self) { - self.last_plain_char_time = None; - self.consecutive_plain_char_burst = 0; - self.burst_window_until = None; - self.active = false; - self.buffer.clear(); - self.pending_first_char = None; - } -} - -pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { - if retro_chars == 0 { - return before.len(); - } - before - .char_indices() - .rev() - .nth(retro_chars.saturating_sub(1)) - .map(|(idx, _)| idx) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - /// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows, - /// that held char should eventually flush as normal typed input (not as a paste). - #[test] - fn ascii_first_char_is_held_then_flushes_as_typed() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1); - assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a'))); - assert!(!burst.is_active()); - } - - /// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever - /// rendering the first one, then flush the whole buffered payload as a paste. - #[test] - fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - let t1 = t0 + Duration::from_millis(1); - assert!(matches!( - burst.on_plain_char('b', t1), - CharDecision::BeginBufferFromPending - )); - burst.append_char_to_buffer('b', t1); - - let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1); - assert!(matches!( - burst.flush_if_due(t2), - FlushResult::Paste(ref s) if s == "ab" - )); - } - - /// Behavior: when non-char input is about to be applied, we flush any transient burst state - /// immediately (including a single pending ASCII char) so state doesn't leak across inputs. - #[test] - fn flush_before_modified_input_includes_pending_first_char() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - assert_eq!(burst.flush_before_modified_input(), Some("a".to_string())); - assert!(!burst.is_active()); - } - - /// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks - /// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified. - #[test] - fn decide_begin_buffer_only_triggers_for_pastey_prefixes() { - let mut burst = PasteBurst::default(); - let now = Instant::now(); - - assert!(burst.decide_begin_buffer(now, "ab", 2).is_none()); - assert!(!burst.is_active()); - - let grab = burst - .decide_begin_buffer(now, "a b", 2) - .expect("whitespace should be considered paste-like"); - assert_eq!(grab.start_byte, 1); - assert_eq!(grab.grabbed, " b"); - assert!(burst.is_active()); - } - - /// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so - /// a slightly-late Enter still inserts a newline instead of submitting. - #[test] - fn newline_suppression_window_outlives_buffer_flush() { - let mut burst = PasteBurst::default(); - let t0 = Instant::now(); - assert!(matches!( - burst.on_plain_char('a', t0), - CharDecision::RetainFirstChar - )); - - let t1 = t0 + Duration::from_millis(1); - assert!(matches!( - burst.on_plain_char('b', t1), - CharDecision::BeginBufferFromPending - )); - burst.append_char_to_buffer('b', t1); - - let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1); - assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab")); - assert!(!burst.is_active()); - - assert!(burst.newline_should_insert_instead_of_submit(t2)); - let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1); - assert!(!burst.newline_should_insert_instead_of_submit(t3)); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/popup_consts.rs b/codex-rs/tui2/src/bottom_pane/popup_consts.rs deleted file mode 100644 index 2cabe389b1..0000000000 --- a/codex-rs/tui2/src/bottom_pane/popup_consts.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Shared popup-related constants for bottom pane widgets. - -use crossterm::event::KeyCode; -use ratatui::text::Line; - -use crate::key_hint; - -/// Maximum number of rows any popup should attempt to display. -/// Keep this consistent across all popups for a uniform feel. -pub(crate) const MAX_POPUP_ROWS: usize = 8; - -/// Standard footer hint text used by popups. -pub(crate) fn standard_popup_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to confirm or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to go back".into(), - ]) -} diff --git a/codex-rs/tui2/src/bottom_pane/prompt_args.rs b/codex-rs/tui2/src/bottom_pane/prompt_args.rs deleted file mode 100644 index 48c3cedfab..0000000000 --- a/codex-rs/tui2/src/bottom_pane/prompt_args.rs +++ /dev/null @@ -1,406 +0,0 @@ -use codex_protocol::custom_prompts::CustomPrompt; -use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; -use lazy_static::lazy_static; -use regex_lite::Regex; -use shlex::Shlex; -use std::collections::HashMap; -use std::collections::HashSet; - -lazy_static! { - static ref PROMPT_ARG_REGEX: Regex = - Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort()); -} - -#[derive(Debug)] -pub enum PromptArgsError { - MissingAssignment { token: String }, - MissingKey { token: String }, -} - -impl PromptArgsError { - fn describe(&self, command: &str) -> String { - match self { - PromptArgsError::MissingAssignment { token } => format!( - "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." - ), - PromptArgsError::MissingKey { token } => { - format!("Could not parse {command}: expected a name before '=' in '{token}'.") - } - } - } -} - -#[derive(Debug)] -pub enum PromptExpansionError { - Args { - command: String, - error: PromptArgsError, - }, - MissingArgs { - command: String, - missing: Vec, - }, -} - -impl PromptExpansionError { - pub fn user_message(&self) -> String { - match self { - PromptExpansionError::Args { command, error } => error.describe(command), - PromptExpansionError::MissingArgs { command, missing } => { - let list = missing.join(", "); - format!( - "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." - ) - } - } - } -} - -/// Parse a first-line slash command of the form `/name `. -/// Returns `(name, rest_after_name)` if the line begins with `/` and contains -/// a non-empty name; otherwise returns `None`. -pub fn parse_slash_name(line: &str) -> Option<(&str, &str)> { - let stripped = line.strip_prefix('/')?; - let mut name_end = stripped.len(); - for (idx, ch) in stripped.char_indices() { - if ch.is_whitespace() { - name_end = idx; - break; - } - } - let name = &stripped[..name_end]; - if name.is_empty() { - return None; - } - let rest = stripped[name_end..].trim_start(); - Some((name, rest)) -} - -/// Parse positional arguments using shlex semantics (supports quoted tokens). -pub fn parse_positional_args(rest: &str) -> Vec { - Shlex::new(rest).collect() -} - -/// Extracts the unique placeholder variable names from a prompt template. -/// -/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*` -/// (for example `$USER`). The function returns the variable names without -/// the leading `$`, de-duplicated and in the order of first appearance. -pub fn prompt_argument_names(content: &str) -> Vec { - let mut seen = HashSet::new(); - let mut names = Vec::new(); - for m in PROMPT_ARG_REGEX.find_iter(content) { - if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' { - continue; - } - let name = &content[m.start() + 1..m.end()]; - // Exclude special positional aggregate token from named args. - if name == "ARGUMENTS" { - continue; - } - let name = name.to_string(); - if seen.insert(name.clone()) { - names.push(name); - } - } - names -} - -/// Parses the `key=value` pairs that follow a custom prompt name. -/// -/// The input is split using shlex rules, so quoted values are supported -/// (for example `USER="Alice Smith"`). The function returns a map of parsed -/// arguments, or an error if a token is missing `=` or if the key is empty. -pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { - let mut map = HashMap::new(); - if rest.trim().is_empty() { - return Ok(map); - } - - for token in Shlex::new(rest) { - let Some((key, value)) = token.split_once('=') else { - return Err(PromptArgsError::MissingAssignment { token }); - }; - if key.is_empty() { - return Err(PromptArgsError::MissingKey { token }); - } - map.insert(key.to_string(), value.to_string()); - } - Ok(map) -} - -/// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. -/// -/// If the text does not start with `/prompts:`, or if no prompt named `name` exists, -/// the function returns `Ok(None)`. On success it returns -/// `Ok(Some(expanded))`; otherwise it returns a descriptive error. -pub fn expand_custom_prompt( - text: &str, - custom_prompts: &[CustomPrompt], -) -> Result, PromptExpansionError> { - let Some((name, rest)) = parse_slash_name(text) else { - return Ok(None); - }; - - // Only handle custom prompts when using the explicit prompts prefix with a colon. - let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { - return Ok(None); - }; - - let prompt = match custom_prompts.iter().find(|p| p.name == prompt_name) { - Some(prompt) => prompt, - None => return Ok(None), - }; - // If there are named placeholders, expect key=value inputs. - let required = prompt_argument_names(&prompt.content); - if !required.is_empty() { - let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { - command: format!("/{name}"), - error, - })?; - let missing: Vec = required - .into_iter() - .filter(|k| !inputs.contains_key(k)) - .collect(); - if !missing.is_empty() { - return Err(PromptExpansionError::MissingArgs { - command: format!("/{name}"), - missing, - }); - } - let content = &prompt.content; - let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { - if let Some(matched) = caps.get(0) - && matched.start() > 0 - && content.as_bytes()[matched.start() - 1] == b'$' - { - return matched.as_str().to_string(); - } - let whole = &caps[0]; - let key = &whole[1..]; - inputs - .get(key) - .cloned() - .unwrap_or_else(|| whole.to_string()) - }); - return Ok(Some(replaced.into_owned())); - } - - // Otherwise, treat it as numeric/positional placeholder prompt (or none). - let pos_args: Vec = Shlex::new(rest).collect(); - let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); - Ok(Some(expanded)) -} - -/// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. -pub fn prompt_has_numeric_placeholders(content: &str) -> bool { - if content.contains("$ARGUMENTS") { - return true; - } - let bytes = content.as_bytes(); - let mut i = 0; - while i + 1 < bytes.len() { - if bytes[i] == b'$' { - let b1 = bytes[i + 1]; - if (b'1'..=b'9').contains(&b1) { - return true; - } - } - i += 1; - } - false -} - -/// Extract positional arguments from a composer first line like "/name a b" for a given prompt name. -/// Returns empty when the command name does not match or when there are no args. -pub fn extract_positional_args_for_prompt_line(line: &str, prompt_name: &str) -> Vec { - let trimmed = line.trim_start(); - let Some(rest) = trimmed.strip_prefix('/') else { - return Vec::new(); - }; - // Require the explicit prompts prefix for custom prompt invocations. - let Some(after_prefix) = rest.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) else { - return Vec::new(); - }; - let mut parts = after_prefix.splitn(2, char::is_whitespace); - let cmd = parts.next().unwrap_or(""); - if cmd != prompt_name { - return Vec::new(); - } - let args_str = parts.next().unwrap_or("").trim(); - if args_str.is_empty() { - return Vec::new(); - } - parse_positional_args(args_str) -} - -/// If the prompt only uses numeric placeholders and the first line contains -/// positional args for it, expand and return Some(expanded); otherwise None. -pub fn expand_if_numeric_with_positional_args( - prompt: &CustomPrompt, - first_line: &str, -) -> Option { - if !prompt_argument_names(&prompt.content).is_empty() { - return None; - } - if !prompt_has_numeric_placeholders(&prompt.content) { - return None; - } - let args = extract_positional_args_for_prompt_line(first_line, &prompt.name); - if args.is_empty() { - return None; - } - Some(expand_numeric_placeholders(&prompt.content, &args)) -} - -/// Expand `$1..$9` and `$ARGUMENTS` in `content` with values from `args`. -pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { - let mut out = String::with_capacity(content.len()); - let mut i = 0; - let mut cached_joined_args: Option = None; - while let Some(off) = content[i..].find('$') { - let j = i + off; - out.push_str(&content[i..j]); - let rest = &content[j..]; - let bytes = rest.as_bytes(); - if bytes.len() >= 2 { - match bytes[1] { - b'$' => { - out.push_str("$$"); - i = j + 2; - continue; - } - b'1'..=b'9' => { - let idx = (bytes[1] - b'1') as usize; - if let Some(val) = args.get(idx) { - out.push_str(val); - } - i = j + 2; - continue; - } - _ => {} - } - } - if rest.len() > "ARGUMENTS".len() && rest[1..].starts_with("ARGUMENTS") { - if !args.is_empty() { - let joined = cached_joined_args.get_or_insert_with(|| args.join(" ")); - out.push_str(joined); - } - i = j + 1 + "ARGUMENTS".len(); - continue; - } - out.push('$'); - i = j + 1; - } - out.push_str(&content[i..]); - out -} - -/// Constructs a command text for a custom prompt with arguments. -/// Returns the text and the cursor position (inside the first double quote). -pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { - let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); - let mut cursor: usize = text.len(); - for (i, arg) in args.iter().enumerate() { - text.push_str(format!(" {arg}=\"\"").as_str()); - if i == 0 { - cursor = text.len() - 1; // inside first "" - } - } - (text, cursor) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expand_arguments_basic() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]; - - let out = - expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); - assert_eq!(out, Some("Review Alice changes on main".to_string())); - } - - #[test] - fn quoted_values_ok() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Pair $USER with $BRANCH".to_string(), - description: None, - argument_hint: None, - }]; - - let out = expand_custom_prompt( - "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", - &prompts, - ) - .unwrap(); - assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); - } - - #[test] - fn invalid_arg_token_reports_error() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes".to_string(), - description: None, - argument_hint: None, - }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) - .unwrap_err() - .user_message(); - assert!(err.contains("expected key=value")); - } - - #[test] - fn missing_required_args_reports_error() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "Review $USER changes on $BRANCH".to_string(), - description: None, - argument_hint: None, - }]; - let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) - .unwrap_err() - .user_message(); - assert!(err.to_lowercase().contains("missing required args")); - assert!(err.contains("BRANCH")); - } - - #[test] - fn escaped_placeholder_is_ignored() { - assert_eq!( - prompt_argument_names("literal $$USER"), - Vec::::new() - ); - assert_eq!( - prompt_argument_names("literal $$USER and $REAL"), - vec!["REAL".to_string()] - ); - } - - #[test] - fn escaped_placeholder_remains_literal() { - let prompts = vec![CustomPrompt { - name: "my-prompt".to_string(), - path: "/tmp/my-prompt.md".to_string().into(), - content: "literal $$USER".to_string(), - description: None, - argument_hint: None, - }]; - - let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); - assert_eq!(out, Some("literal $$USER".to_string())); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs deleted file mode 100644 index ae33aeada4..0000000000 --- a/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; - -use crate::key_hint; -use crate::render::renderable::Renderable; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; - -/// Widget that displays a list of user messages queued while a turn is in progress. -pub(crate) struct QueuedUserMessages { - pub messages: Vec, -} - -impl QueuedUserMessages { - pub(crate) fn new() -> Self { - Self { - messages: Vec::new(), - } - } - - fn as_renderable(&self, width: u16) -> Box { - if self.messages.is_empty() || width < 4 { - return Box::new(()); - } - - let mut lines = vec![]; - - for message in &self.messages { - let wrapped = word_wrap_lines( - message.lines().map(|line| line.dim().italic()), - RtOptions::new(width as usize) - .initial_indent(Line::from(" ↳ ".dim())) - .subsequent_indent(Line::from(" ")), - ); - let len = wrapped.len(); - for line in wrapped.into_iter().take(3) { - lines.push(line); - } - if len > 3 { - lines.push(Line::from(" …".dim().italic())); - } - } - - lines.push( - Line::from(vec![ - " ".into(), - key_hint::alt(KeyCode::Up).into(), - " edit".into(), - ]) - .dim(), - ); - - Paragraph::new(lines).into() - } -} - -impl Renderable for QueuedUserMessages { - fn render(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - self.as_renderable(area.width).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - self.as_renderable(width).desired_height(width) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - - #[test] - fn desired_height_empty() { - let queue = QueuedUserMessages::new(); - assert_eq!(queue.desired_height(40), 0); - } - - #[test] - fn desired_height_one_message() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - assert_eq!(queue.desired_height(40), 2); - } - - #[test] - fn render_one_message() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_one_message", format!("{buf:?}")); - } - - #[test] - fn render_two_messages() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - queue.messages.push("This is another message".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_two_messages", format!("{buf:?}")); - } - - #[test] - fn render_more_than_three_messages() { - let mut queue = QueuedUserMessages::new(); - queue.messages.push("Hello, world!".to_string()); - queue.messages.push("This is another message".to_string()); - queue.messages.push("This is a third message".to_string()); - queue.messages.push("This is a fourth message".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_more_than_three_messages", format!("{buf:?}")); - } - - #[test] - fn render_wrapped_message() { - let mut queue = QueuedUserMessages::new(); - queue - .messages - .push("This is a longer message that should be wrapped".to_string()); - queue.messages.push("This is another message".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_wrapped_message", format!("{buf:?}")); - } - - #[test] - fn render_many_line_message() { - let mut queue = QueuedUserMessages::new(); - queue - .messages - .push("This is\na message\nwith many\nlines".to_string()); - let width = 40; - let height = queue.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - queue.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_many_line_message", format!("{buf:?}")); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/scroll_state.rs b/codex-rs/tui2/src/bottom_pane/scroll_state.rs deleted file mode 100644 index a9728d1a0d..0000000000 --- a/codex-rs/tui2/src/bottom_pane/scroll_state.rs +++ /dev/null @@ -1,115 +0,0 @@ -/// Generic scroll/selection state for a vertical list menu. -/// -/// Encapsulates the common behavior of a selectable list that supports: -/// - Optional selection (None when list is empty) -/// - Wrap-around navigation on Up/Down -/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct ScrollState { - pub selected_idx: Option, - pub scroll_top: usize, -} - -impl ScrollState { - pub fn new() -> Self { - Self { - selected_idx: None, - scroll_top: 0, - } - } - - /// Reset selection and scroll. - pub fn reset(&mut self) { - self.selected_idx = None; - self.scroll_top = 0; - } - - /// Clamp selection to be within the [0, len-1] range, or None when empty. - pub fn clamp_selection(&mut self, len: usize) { - self.selected_idx = match len { - 0 => None, - _ => Some(self.selected_idx.unwrap_or(0).min(len - 1)), - }; - if len == 0 { - self.scroll_top = 0; - } - } - - /// Move selection up by one, wrapping to the bottom when necessary. - pub fn move_up_wrap(&mut self, len: usize) { - if len == 0 { - self.selected_idx = None; - self.scroll_top = 0; - return; - } - self.selected_idx = Some(match self.selected_idx { - Some(idx) if idx > 0 => idx - 1, - Some(_) => len - 1, - None => 0, - }); - } - - /// Move selection down by one, wrapping to the top when necessary. - pub fn move_down_wrap(&mut self, len: usize) { - if len == 0 { - self.selected_idx = None; - self.scroll_top = 0; - return; - } - self.selected_idx = Some(match self.selected_idx { - Some(idx) if idx + 1 < len => idx + 1, - _ => 0, - }); - } - - /// Adjust `scroll_top` so that the current `selected_idx` is visible within - /// the window of `visible_rows`. - pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) { - if len == 0 || visible_rows == 0 { - self.scroll_top = 0; - return; - } - if let Some(sel) = self.selected_idx { - if sel < self.scroll_top { - self.scroll_top = sel; - } else { - let bottom = self.scroll_top + visible_rows - 1; - if sel > bottom { - self.scroll_top = sel + 1 - visible_rows; - } - } - } else { - self.scroll_top = 0; - } - } -} - -#[cfg(test)] -mod tests { - use super::ScrollState; - - #[test] - fn wrap_navigation_and_visibility() { - let mut s = ScrollState::new(); - let len = 10; - let vis = 5; - - s.clamp_selection(len); - assert_eq!(s.selected_idx, Some(0)); - s.ensure_visible(len, vis); - assert_eq!(s.scroll_top, 0); - - s.move_up_wrap(len); - s.ensure_visible(len, vis); - assert_eq!(s.selected_idx, Some(len - 1)); - match s.selected_idx { - Some(sel) => assert!(s.scroll_top <= sel), - None => panic!("expected Some(selected_idx) after wrap"), - } - - s.move_down_wrap(len); - s.ensure_visible(len, vis); - assert_eq!(s.selected_idx, Some(0)); - assert_eq!(s.scroll_top, 0); - } -} diff --git a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs deleted file mode 100644 index b340e18883..0000000000 --- a/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs +++ /dev/null @@ -1,419 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -// Note: Table-based layout previously used Constraint; the manual renderer -// below no longer requires it. -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::text::Span; -use ratatui::widgets::Widget; -use unicode_width::UnicodeWidthChar; -use unicode_width::UnicodeWidthStr; - -use crate::key_hint::KeyBinding; - -use super::scroll_state::ScrollState; - -/// A generic representation of a display row for selection popups. -pub(crate) struct GenericDisplayRow { - pub name: String, - pub display_shortcut: Option, - pub match_indices: Option>, // indices to bold (char positions) - pub description: Option, // optional grey text after the name - pub wrap_indent: Option, // optional indent for wrapped lines -} - -pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; - - let width = width.max(1) as usize; - let opts = RtOptions::new(width) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from("")); - word_wrap_line(line, opts) -} - -fn line_width(line: &Line<'_>) -> usize { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum() -} - -fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let mut used = 0usize; - let mut spans_out: Vec> = Vec::new(); - - for span in line.spans { - let text = span.content.into_owned(); - let style = span.style; - let span_width = UnicodeWidthStr::width(text.as_str()); - - if span_width == 0 { - spans_out.push(Span::styled(text, style)); - continue; - } - - if used >= max_width { - break; - } - - if used + span_width <= max_width { - used += span_width; - spans_out.push(Span::styled(text, style)); - continue; - } - - let mut truncated = String::new(); - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if used + ch_width > max_width { - break; - } - truncated.push(ch); - used += ch_width; - } - - if !truncated.is_empty() { - spans_out.push(Span::styled(truncated, style)); - } - - break; - } - - Line::from(spans_out) -} - -fn truncate_line_with_ellipsis_if_overflow(line: Line<'static>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let width = line_width(&line); - if width <= max_width { - return line; - } - - let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); - let mut spans = truncated.spans; - let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default(); - spans.push(Span::styled("…", ellipsis_style)); - Line::from(spans) -} - -/// Compute a shared description-column start based on the widest visible name -/// plus two spaces of padding. Ensures at least one column is left for the -/// description. -fn compute_desc_col( - rows_all: &[GenericDisplayRow], - start_idx: usize, - visible_items: usize, - content_width: u16, -) -> usize { - let visible_range = start_idx..(start_idx + visible_items); - let max_name_width = rows_all - .iter() - .enumerate() - .filter(|(i, _)| visible_range.contains(i)) - .map(|(_, r)| Line::from(r.name.clone()).width()) - .max() - .unwrap_or(0); - let mut desc_col = max_name_width.saturating_add(2); - if (desc_col as u16) >= content_width { - desc_col = content_width.saturating_sub(1) as usize; - } - desc_col -} - -/// Determine how many spaces to indent wrapped lines for a row. -fn wrap_indent(row: &GenericDisplayRow, desc_col: usize, max_width: u16) -> usize { - let max_indent = max_width.saturating_sub(1) as usize; - let indent = row.wrap_indent.unwrap_or_else(|| { - if row.description.is_some() { - desc_col - } else { - 0 - } - }); - indent.min(max_indent) -} - -/// Build the full display line for a row with the description padded to start -/// at `desc_col`. Applies fuzzy-match bolding when indices are present and -/// dims the description. -fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { - // Enforce single-line name: allow at most desc_col - 2 cells for name, - // reserving two spaces before the description column. - let name_limit = row - .description - .as_ref() - .map(|_| desc_col.saturating_sub(2)) - .unwrap_or(usize::MAX); - - let mut name_spans: Vec = Vec::with_capacity(row.name.len()); - let mut used_width = 0usize; - let mut truncated = false; - - if let Some(idxs) = row.match_indices.as_ref() { - let mut idx_iter = idxs.iter().peekable(); - for (char_idx, ch) in row.name.chars().enumerate() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - let next_width = used_width.saturating_add(ch_w); - if next_width > name_limit { - truncated = true; - break; - } - used_width = next_width; - - if idx_iter.peek().is_some_and(|next| **next == char_idx) { - idx_iter.next(); - name_spans.push(ch.to_string().bold()); - } else { - name_spans.push(ch.to_string().into()); - } - } - } else { - for ch in row.name.chars() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - let next_width = used_width.saturating_add(ch_w); - if next_width > name_limit { - truncated = true; - break; - } - used_width = next_width; - name_spans.push(ch.to_string().into()); - } - } - - if truncated { - // If there is at least one cell available, add an ellipsis. - // When name_limit is 0, we still show an ellipsis to indicate truncation. - name_spans.push("…".into()); - } - - let this_name_width = Line::from(name_spans.clone()).width(); - let mut full_spans: Vec = name_spans; - if let Some(display_shortcut) = row.display_shortcut { - full_spans.push(" (".into()); - full_spans.push(display_shortcut.into()); - full_spans.push(")".into()); - } - if let Some(desc) = row.description.as_ref() { - let gap = desc_col.saturating_sub(this_name_width); - if gap > 0 { - full_spans.push(" ".repeat(gap).into()); - } - full_spans.push(desc.clone().dim()); - } - Line::from(full_spans) -} - -/// Render a list of rows using the provided ScrollState, with shared styling -/// and behavior for selection popups. -pub(crate) fn render_rows( - area: Rect, - buf: &mut Buffer, - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - empty_message: &str, -) { - if rows_all.is_empty() { - if area.height > 0 { - Line::from(empty_message.dim().italic()).render(area, buf); - } - return; - } - - // Determine which logical rows (items) are visible given the selection and - // the max_results clamp. Scrolling is still item-based for simplicity. - let visible_items = max_results - .min(rows_all.len()) - .min(area.height.max(1) as usize); - - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } - } - - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); - - // Render items, wrapping descriptions and aligning wrapped lines under the - // shared description column. Stop when we run out of vertical space. - let mut cur_y = area.y; - for (i, row) in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - { - if cur_y >= area.y + area.height { - break; - } - - let mut full_line = build_full_line(row, desc_col); - if Some(i) == state.selected_idx { - // Match previous behavior: cyan + bold for the selected row. - // Reset the style first to avoid inheriting dim from keyboard shortcuts. - full_line.spans.iter_mut().for_each(|span| { - span.style = Style::default().fg(Color::Cyan).bold(); - }); - } - - // Wrap with subsequent indent aligned to the description column. - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; - let continuation_indent = wrap_indent(row, desc_col, area.width); - let options = RtOptions::new(area.width as usize) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(continuation_indent))); - let wrapped = word_wrap_line(&full_line, options); - - // Render the wrapped lines. - for line in wrapped { - if cur_y >= area.y + area.height { - break; - } - line.render( - Rect { - x: area.x, - y: cur_y, - width: area.width, - height: 1, - }, - buf, - ); - cur_y = cur_y.saturating_add(1); - } - } -} - -/// Render rows as a single line each (no wrapping), truncating overflow with an ellipsis. -pub(crate) fn render_rows_single_line( - area: Rect, - buf: &mut Buffer, - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - empty_message: &str, -) { - if rows_all.is_empty() { - if area.height > 0 { - Line::from(empty_message.dim().italic()).render(area, buf); - } - return; - } - - let visible_items = max_results - .min(rows_all.len()) - .min(area.height.max(1) as usize); - - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } - } - - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width); - - let mut cur_y = area.y; - for (i, row) in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - { - if cur_y >= area.y + area.height { - break; - } - - let mut full_line = build_full_line(row, desc_col); - if Some(i) == state.selected_idx { - full_line.spans.iter_mut().for_each(|span| { - span.style = Style::default().fg(Color::Cyan).bold(); - }); - } - - let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize); - full_line.render( - Rect { - x: area.x, - y: cur_y, - width: area.width, - height: 1, - }, - buf, - ); - cur_y = cur_y.saturating_add(1); - } -} - -/// Compute the number of terminal rows required to render up to `max_results` -/// items from `rows_all` given the current scroll/selection state and the -/// available `width`. Accounts for description wrapping and alignment so the -/// caller can allocate sufficient vertical space. -pub(crate) fn measure_rows_height( - rows_all: &[GenericDisplayRow], - state: &ScrollState, - max_results: usize, - width: u16, -) -> u16 { - if rows_all.is_empty() { - return 1; // placeholder "no matches" line - } - - let content_width = width.saturating_sub(1).max(1); - - let visible_items = max_results.min(rows_all.len()); - let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); - if let Some(sel) = state.selected_idx { - if sel < start_idx { - start_idx = sel; - } else if visible_items > 0 { - let bottom = start_idx + visible_items - 1; - if sel > bottom { - start_idx = sel + 1 - visible_items; - } - } - } - - let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width); - - use crate::wrapping::RtOptions; - use crate::wrapping::word_wrap_line; - let mut total: u16 = 0; - for row in rows_all - .iter() - .enumerate() - .skip(start_idx) - .take(visible_items) - .map(|(_, r)| r) - { - let full_line = build_full_line(row, desc_col); - let continuation_indent = wrap_indent(row, desc_col, content_width); - let opts = RtOptions::new(content_width as usize) - .initial_indent(Line::from("")) - .subsequent_indent(Line::from(" ".repeat(continuation_indent))); - let wrapped_lines = word_wrap_line(&full_line, opts).len(); - total = total.saturating_add(wrapped_lines as u16); - } - total.max(1) -} diff --git a/codex-rs/tui2/src/bottom_pane/skill_popup.rs b/codex-rs/tui2/src/bottom_pane/skill_popup.rs deleted file mode 100644 index d4d4a6828f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/skill_popup.rs +++ /dev/null @@ -1,196 +0,0 @@ -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; -use ratatui::layout::Rect; -use ratatui::text::Line; -use ratatui::widgets::Widget; -use ratatui::widgets::WidgetRef; - -use super::popup_consts::MAX_POPUP_ROWS; -use super::scroll_state::ScrollState; -use super::selection_popup_common::GenericDisplayRow; -use super::selection_popup_common::render_rows_single_line; -use crate::key_hint; -use crate::render::Insets; -use crate::render::RectExt; -use codex_common::fuzzy_match::fuzzy_match; -use codex_core::skills::model::SkillMetadata; - -use crate::text_formatting::truncate_text; - -pub(crate) struct SkillPopup { - query: String, - skills: Vec, - state: ScrollState, -} - -impl SkillPopup { - pub(crate) fn new(skills: Vec) -> Self { - Self { - query: String::new(), - skills, - state: ScrollState::new(), - } - } - - pub(crate) fn set_skills(&mut self, skills: Vec) { - self.skills = skills; - self.clamp_selection(); - } - - pub(crate) fn set_query(&mut self, query: &str) { - self.query = query.to_string(); - self.clamp_selection(); - } - - pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 { - let rows = self.rows_from_matches(self.filtered()); - let visible = rows.len().clamp(1, MAX_POPUP_ROWS); - (visible as u16).saturating_add(2) - } - - pub(crate) fn move_up(&mut self) { - let len = self.filtered_items().len(); - self.state.move_up_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - pub(crate) fn move_down(&mut self) { - let len = self.filtered_items().len(); - self.state.move_down_wrap(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { - let matches = self.filtered_items(); - let idx = self.state.selected_idx?; - let skill_idx = matches.get(idx)?; - self.skills.get(*skill_idx) - } - - fn clamp_selection(&mut self) { - let len = self.filtered_items().len(); - self.state.clamp_selection(len); - self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); - } - - fn filtered_items(&self) -> Vec { - self.filtered().into_iter().map(|(idx, _, _)| idx).collect() - } - - fn rows_from_matches( - &self, - matches: Vec<(usize, Option>, i32)>, - ) -> Vec { - matches - .into_iter() - .map(|(idx, indices, _score)| { - let skill = &self.skills[idx]; - let name = truncate_text(skill_display_name(skill), 21); - let description = skill_description(skill).to_string(); - GenericDisplayRow { - name, - match_indices: indices, - display_shortcut: None, - description: Some(description), - wrap_indent: None, - } - }) - .collect() - } - - fn filtered(&self) -> Vec<(usize, Option>, i32)> { - let filter = self.query.trim(); - let mut out: Vec<(usize, Option>, i32)> = Vec::new(); - - if filter.is_empty() { - for (idx, _skill) in self.skills.iter().enumerate() { - out.push((idx, None, 0)); - } - return out; - } - - for (idx, skill) in self.skills.iter().enumerate() { - let display_name = skill_display_name(skill); - if let Some((indices, score)) = fuzzy_match(display_name, filter) { - out.push((idx, Some(indices), score)); - } else if display_name != skill.name - && let Some((_indices, score)) = fuzzy_match(&skill.name, filter) - { - out.push((idx, None, score)); - } - } - - out.sort_by(|a, b| { - a.2.cmp(&b.2).then_with(|| { - let an = skill_display_name(&self.skills[a.0]); - let bn = skill_display_name(&self.skills[b.0]); - an.cmp(bn) - }) - }); - - out - } -} - -impl WidgetRef for SkillPopup { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let (list_area, hint_area) = if area.height > 2 { - let [list_area, _spacer_area, hint_area] = Layout::vertical([ - Constraint::Length(area.height - 2), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas(area); - (list_area, Some(hint_area)) - } else { - (area, None) - }; - let rows = self.rows_from_matches(self.filtered()); - render_rows_single_line( - list_area.inset(Insets::tlbr(0, 2, 0, 0)), - buf, - &rows, - &self.state, - MAX_POPUP_ROWS, - "no skills", - ); - if let Some(hint_area) = hint_area { - let hint_area = Rect { - x: hint_area.x + 2, - y: hint_area.y, - width: hint_area.width.saturating_sub(2), - height: hint_area.height, - }; - skill_popup_hint_line().render(hint_area, buf); - } - } -} - -fn skill_popup_hint_line() -> Line<'static> { - Line::from(vec![ - "Press ".into(), - key_hint::plain(KeyCode::Enter).into(), - " to select or ".into(), - key_hint::plain(KeyCode::Esc).into(), - " to close".into(), - ]) -} - -fn skill_display_name(skill: &SkillMetadata) -> &str { - skill - .interface - .as_ref() - .and_then(|interface| interface.display_name.as_deref()) - .unwrap_or(&skill.name) -} - -fn skill_description(skill: &SkillMetadata) -> &str { - skill - .interface - .as_ref() - .and_then(|interface| interface.short_description.as_deref()) - .or(skill.short_description.as_deref()) - .unwrap_or(&skill.description) -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap deleted file mode 100644 index 00821b7910..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap deleted file mode 100644 index 1a34b29f92..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap deleted file mode 100644 index d9395f2b05..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap deleted file mode 100644 index d9395f2b05..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap deleted file mode 100644 index 9e93b8d683..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap deleted file mode 100644 index 1d16779b01..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap deleted file mode 100644 index 9e93b8d683..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap deleted file mode 100644 index 0aa72ca002..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› h " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap deleted file mode 100644 index 1d16779b01..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap deleted file mode 100644 index be9e62f40c..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap deleted file mode 100644 index 3b7711d75f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1005 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap deleted file mode 100644 index 426afbec6e..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap deleted file mode 100644 index dc66d149e4..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› /mo " -" " -" /model choose what model and reasoning effort to use " -" /mention mention a file " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap deleted file mode 100644 index daedb3d888..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› /res " -" " -" " -" " -" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap deleted file mode 100644 index 8f669e1cb9..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› short " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap deleted file mode 100644 index f3c3a319bc..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bad result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap deleted file mode 100644 index 2ab262c229..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bug) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap deleted file mode 100644 index 6bd6846202..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (good result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap deleted file mode 100644 index 1ec33c54ee..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (other) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap deleted file mode 100644 index 09d51329ad..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_disabled.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap deleted file mode 100644 index 6f985f20cc..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_only_queue_hint_enabled.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · tab to queue message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap deleted file mode 100644 index e31cf10f06..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap deleted file mode 100644 index 77a9306adc..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -assertion_line: 473 -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts · Copied " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap deleted file mode 100644 index 157853e73d..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap deleted file mode 100644 index 157853e73d..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap deleted file mode 100644 index 201bec4f62..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap deleted file mode 100644 index 0bc46a989a..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap deleted file mode 100644 index 78af40d9f3..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" shift + tab to change mode " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap deleted file mode 100644 index 2dd8738fe0..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap deleted file mode 100644 index 286acadd8b..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap deleted file mode 100644 index ba860d1f48..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap deleted file mode 100644 index ee592efa26..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_transcript_scrolled_and_selection.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts · pgup/pgdn scroll · home/end jump · 3/42 " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap deleted file mode 100644 index 18d0eb2483..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_footer_note_wraps.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -assertion_line: 640 -expression: "render_lines_with_width(&view, 40)" ---- - - Select Approval Mode - -› 1. Read Only (current) Codex can - read files - - Note: Use /setup-elevated-sandbox to - allow network access. - Press enter to confirm or esc to go ba diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap deleted file mode 100644 index b46a229ad4..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 80)" ---- - - Select Model and Effort - -› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning - quality and coding ability. - 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less - capable. - 3. gpt-4.1-codex Legacy model. Use when you need compatibility - with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap deleted file mode 100644 index bcdc8a3561..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 24)" ---- - - Debug - -› 1. Item 1 - xxxxxxxxx - x - 2. Item 2 - xxxxxxxxx - x - 3. Item 3 - xxxxxxxxx - x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap deleted file mode 100644 index 2cc2578c56..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - Switch between Codex approval presets - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap deleted file mode 100644 index 88a5d14932..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap deleted file mode 100644 index c715e81c9a..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ This is ", - " a message ", - " with many ", - " … ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap deleted file mode 100644 index 1e88bfb5b1..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ↳ This is a third message ", - " ↳ This is a fourth message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap deleted file mode 100644 index 8160a886db..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, - content: [ - " ↳ Hello, world! ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap deleted file mode 100644 index 9b1ef9e5c6..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap deleted file mode 100644 index f46cf990fa..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tui2/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, - content: [ - " ↳ This is a longer message that should", - " be wrapped ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap deleted file mode 100644 index 71504561db..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- - ↳ Queued follow-up question - ⌥ + ↑ edit - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap deleted file mode 100644 index f6c157922a..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interru - - -› Ask Codex to do anything - - 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap deleted file mode 100644 index 6ac4296833..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui2/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interrupt) - ↳ Queued follow-up question - ⌥ + ↑ edit - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap deleted file mode 100644 index e4cc9ffefd..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1002 chars][Pasted Content 1004 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap deleted file mode 100644 index 53e0aee4cf..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap deleted file mode 100644 index 49ffb0d4c8..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap deleted file mode 100644 index 7ecc5bba71..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap deleted file mode 100644 index 9cad17b864..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap deleted file mode 100644 index 2fce42cc26..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap deleted file mode 100644 index 9cad17b864..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap deleted file mode 100644 index 67e616e917..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› h " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap deleted file mode 100644 index 2fce42cc26..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap deleted file mode 100644 index 0272440027..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap deleted file mode 100644 index 6b018021ec..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1005 chars] " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap deleted file mode 100644 index 40098faee0..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap deleted file mode 100644 index 661e82e3ad..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -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 " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap deleted file mode 100644 index df8ea36e63..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 2385 -expression: terminal.backend() ---- -" " -"› /res " -" " -" " -" " -" /resume resume a saved chat " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap deleted file mode 100644 index 498ed76936..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› short " -" " -" " -" " -" " -" " -" " -" " -" 100% context left " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap deleted file mode 100644 index 465f0f9c4f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bad result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap deleted file mode 100644 index a0b5660135..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (bug) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap deleted file mode 100644 index 73074d61fa..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (good result) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap deleted file mode 100644 index 80e4ffeffe..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- -▌ Tell us more (other) -▌ -▌ (optional) Write a short description to help us further - -Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap deleted file mode 100644 index bafa94b09d..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/bottom_pane/feedback_view.rs -expression: rendered ---- - Do you want to upload logs before reporting issue? - - Logs may include the full conversation history of this Codex process - These logs are retained for 90 days and are used solely for troubles - - You can review the exact content of the logs before they’re uploaded - - - -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. - 2. No - 3. Cancel diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap deleted file mode 100644 index a77ca5565b..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 123K used · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap deleted file mode 100644 index 31a1b743b8..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to quit " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap deleted file mode 100644 index 9979372a1b..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" ctrl + c again to interrupt " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap deleted file mode 100644 index b2333b025f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc esc to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap deleted file mode 100644 index 20f9b178b4..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" esc again to edit previous message " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap deleted file mode 100644 index d05ac90a91..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 72% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap deleted file mode 100644 index c95a5dc0b3..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap deleted file mode 100644 index c1f00d4437..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -expression: terminal.backend() ---- -" / for commands ! for shell commands " -" shift + enter for newline tab to queue message " -" @ for file paths ctrl + v to paste images " -" ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap deleted file mode 100644 index be81978c89..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 80)" ---- - - Select Model and Effort - -› 1. gpt-5.1-codex (current) Optimized for Codex. Balance of reasoning - quality and coding ability. - 2. gpt-5.1-codex-mini Optimized for Codex. Cheaper, faster, but less - capable. - 3. gpt-4.1-codex Legacy model. Use when you need compatibility - with older automations. diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap deleted file mode 100644 index 3ce6a3c45f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: "render_lines_with_width(&view, 24)" ---- - - Debug - -› 1. Item 1 - xxxxxxxxx - x - 2. Item 2 - xxxxxxxxx - x - 3. Item 3 - xxxxxxxxx - x diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap deleted file mode 100644 index 512f6bbca6..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - Switch between Codex approval presets - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap deleted file mode 100644 index ddd0f90cd8..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/bottom_pane/list_selection_view.rs -expression: render_lines(&view) ---- - - Select Approval Mode - -› 1. Read Only (current) Codex can read files - 2. Full Access Codex can edit files - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap deleted file mode 100644 index cf1f7248b3..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ This is ", - " a message ", - " with many ", - " … ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap deleted file mode 100644 index 5e403e1bdd..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, - content: [ - " ↳ Hello, world! ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap deleted file mode 100644 index 4484509695..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap deleted file mode 100644 index 16d6361257..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tui/src/bottom_pane/message_queue.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, - content: [ - " ↳ This is a longer message that should", - " be wrapped ", - " ↳ This is another message ", - " alt + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 16, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap deleted file mode 100644 index d2afbf7dbd..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ This is ", - " a message ", - " with many ", - " … ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 13, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 5, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap deleted file mode 100644 index 9d7527d16f..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 5 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ↳ This is a third message ", - " ↳ This is a fourth message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 28, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap deleted file mode 100644 index d47fa97863..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 2 }, - content: [ - " ↳ Hello, world! ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap deleted file mode 100644 index 1f020fec64..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 3 }, - content: [ - " ↳ Hello, world! ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 17, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap deleted file mode 100644 index 4f2917a6c4..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap +++ /dev/null @@ -1,25 +0,0 @@ ---- -source: tui/src/bottom_pane/queued_user_messages.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 40, height: 4 }, - content: [ - " ↳ This is a longer message that should", - " be wrapped ", - " ↳ This is another message ", - " ⌥ + ↑ edit ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 4, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 14, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 4, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM | ITALIC, - x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 14, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap deleted file mode 100644 index 86e3da4573..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- -• Working (0s • esc to interru - - -› Ask Codex to do anything - - 100% context left · ? for sh diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap deleted file mode 100644 index 52f96e8557..0000000000 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area1)" ---- -› Ask Codex to do a diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs deleted file mode 100644 index 903ebe9f82..0000000000 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ /dev/null @@ -1,2139 +0,0 @@ -use crate::key_hint::is_altgr; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Style; -use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::WidgetRef; -use std::cell::Ref; -use std::cell::RefCell; -use std::ops::Range; -use textwrap::Options; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; - -const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; - -fn is_word_separator(ch: char) -> bool { - WORD_SEPARATORS.contains(ch) -} - -#[derive(Debug, Clone)] -struct TextElement { - range: Range, -} - -#[derive(Debug)] -pub(crate) struct TextArea { - text: String, - cursor_pos: usize, - wrap_cache: RefCell>, - preferred_col: Option, - elements: Vec, - kill_buffer: String, -} - -#[derive(Debug, Clone)] -struct WrapCache { - width: u16, - lines: Vec>, -} - -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct TextAreaState { - /// Index into wrapped lines of the first visible line. - scroll: u16, -} - -impl TextArea { - pub fn new() -> Self { - Self { - text: String::new(), - cursor_pos: 0, - wrap_cache: RefCell::new(None), - preferred_col: None, - elements: Vec::new(), - kill_buffer: String::new(), - } - } - - pub fn set_text(&mut self, text: &str) { - self.text = text.to_string(); - self.cursor_pos = self.cursor_pos.clamp(0, self.text.len()); - self.elements.clear(); - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - self.wrap_cache.replace(None); - self.preferred_col = None; - self.kill_buffer.clear(); - } - - pub fn text(&self) -> &str { - &self.text - } - - pub fn insert_str(&mut self, text: &str) { - self.insert_str_at(self.cursor_pos, text); - } - - pub fn insert_str_at(&mut self, pos: usize, text: &str) { - let pos = self.clamp_pos_for_insertion(pos); - self.text.insert_str(pos, text); - self.wrap_cache.replace(None); - if pos <= self.cursor_pos { - self.cursor_pos += text.len(); - } - self.shift_elements(pos, 0, text.len()); - self.preferred_col = None; - } - - pub fn replace_range(&mut self, range: std::ops::Range, text: &str) { - let range = self.expand_range_to_element_boundaries(range); - self.replace_range_raw(range, text); - } - - fn replace_range_raw(&mut self, range: std::ops::Range, text: &str) { - assert!(range.start <= range.end); - let start = range.start.clamp(0, self.text.len()); - let end = range.end.clamp(0, self.text.len()); - let removed_len = end - start; - let inserted_len = text.len(); - if removed_len == 0 && inserted_len == 0 { - return; - } - let diff = inserted_len as isize - removed_len as isize; - - self.text.replace_range(range, text); - self.wrap_cache.replace(None); - self.preferred_col = None; - self.update_elements_after_replace(start, end, inserted_len); - - // Update the cursor position to account for the edit. - self.cursor_pos = if self.cursor_pos < start { - // Cursor was before the edited range – no shift. - self.cursor_pos - } else if self.cursor_pos <= end { - // Cursor was inside the replaced range – move to end of the new text. - start + inserted_len - } else { - // Cursor was after the replaced range – shift by the length diff. - ((self.cursor_pos as isize) + diff) as usize - } - .min(self.text.len()); - - // Ensure cursor is not inside an element - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - } - - pub fn cursor(&self) -> usize { - self.cursor_pos - } - - pub fn set_cursor(&mut self, pos: usize) { - self.cursor_pos = pos.clamp(0, self.text.len()); - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - self.preferred_col = None; - } - - pub fn desired_height(&self, width: u16) -> u16 { - self.wrapped_lines(width).len() as u16 - } - - #[cfg_attr(not(test), allow(dead_code))] - pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.cursor_pos_with_state(area, TextAreaState::default()) - } - - /// Compute the on-screen cursor position taking scrolling into account. - pub fn cursor_pos_with_state(&self, area: Rect, state: TextAreaState) -> Option<(u16, u16)> { - let lines = self.wrapped_lines(area.width); - let effective_scroll = self.effective_scroll(area.height, &lines, state.scroll); - let i = Self::wrapped_line_index_by_start(&lines, self.cursor_pos)?; - let ls = &lines[i]; - let col = self.text[ls.start..self.cursor_pos].width() as u16; - let screen_row = i - .saturating_sub(effective_scroll as usize) - .try_into() - .unwrap_or(0); - Some((area.x + col, area.y + screen_row)) - } - - pub fn is_empty(&self) -> bool { - self.text.is_empty() - } - - fn current_display_col(&self) -> usize { - let bol = self.beginning_of_current_line(); - self.text[bol..self.cursor_pos].width() - } - - fn wrapped_line_index_by_start(lines: &[Range], pos: usize) -> Option { - // partition_point returns the index of the first element for which - // the predicate is false, i.e. the count of elements with start <= pos. - let idx = lines.partition_point(|r| r.start <= pos); - if idx == 0 { None } else { Some(idx - 1) } - } - - fn move_to_display_col_on_line( - &mut self, - line_start: usize, - line_end: usize, - target_col: usize, - ) { - let mut width_so_far = 0usize; - for (i, g) in self.text[line_start..line_end].grapheme_indices(true) { - width_so_far += g.width(); - if width_so_far > target_col { - self.cursor_pos = line_start + i; - // Avoid landing inside an element; round to nearest boundary - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - return; - } - } - self.cursor_pos = line_end; - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - } - - fn beginning_of_line(&self, pos: usize) -> usize { - self.text[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) - } - fn beginning_of_current_line(&self) -> usize { - self.beginning_of_line(self.cursor_pos) - } - - fn end_of_line(&self, pos: usize) -> usize { - self.text[pos..] - .find('\n') - .map(|i| i + pos) - .unwrap_or(self.text.len()) - } - fn end_of_current_line(&self) -> usize { - self.end_of_line(self.cursor_pos) - } - - pub fn input(&mut self, event: KeyEvent) { - match event { - // Some terminals (or configurations) send Control key chords as - // C0 control characters without reporting the CONTROL modifier. - // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get - // inserted as literal control bytes. - KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { - self.move_cursor_left(); - } - KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { - self.move_cursor_right(); - } - KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { - self.move_cursor_up(); - } - KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { - self.move_cursor_down(); - } - KeyEvent { - code: KeyCode::Char(c), - // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, - // because many terminals map Option/Meta combos to ALT+ (e.g. ESC f/ESC b) - // for word navigation. Those are handled explicitly below. - modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, - .. - } => self.insert_str(&c.to_string()), - KeyEvent { - code: KeyCode::Char('j' | 'm'), - modifiers: KeyModifiers::CONTROL, - .. - } - | KeyEvent { - code: KeyCode::Enter, - .. - } => self.insert_str("\n"), - KeyEvent { - code: KeyCode::Char('h'), - modifiers, - .. - } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.delete_backward_word() - }, - // Windows AltGr generates ALT|CONTROL; treat as a plain character input unless - // we match a specific Control+Alt binding above. - KeyEvent { - code: KeyCode::Char(c), - modifiers, - .. - } if is_altgr(modifiers) => self.insert_str(&c.to_string()), - KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_backward_word(), - KeyEvent { - code: KeyCode::Backspace, - .. - } - | KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_backward(1), - KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::ALT, - .. - } => self.delete_forward_word(), - KeyEvent { - code: KeyCode::Delete, - .. - } - | KeyEvent { - code: KeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL, - .. - } => self.delete_forward(1), - - KeyEvent { - code: KeyCode::Char('w'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.delete_backward_word(); - } - // Meta-b -> move to beginning of previous word - // Meta-f -> move to end of next word - // Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT). - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::ALT, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_beginning_of_line(); - } - KeyEvent { - code: KeyCode::Char('k'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.kill_to_end_of_line(); - } - KeyEvent { - code: KeyCode::Char('y'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.yank(); - } - - // Cursor movement - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('b'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_left(); - } - KeyEvent { - code: KeyCode::Char('f'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_right(); - } - KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_up(); - } - KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_down(); - } - // Some terminals send Alt+Arrow for word-wise movement: - // Option/Left -> Alt+Left (previous word start) - // Option/Right -> Alt+Right (next word end) - KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.beginning_of_previous_word()); - } - KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::ALT, - .. - } - | KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.set_cursor(self.end_of_next_word()); - } - KeyEvent { - code: KeyCode::Up, .. - } => { - self.move_cursor_up(); - } - KeyEvent { - code: KeyCode::Down, - .. - } => { - self.move_cursor_down(); - } - KeyEvent { - code: KeyCode::Home, - .. - } => { - self.move_cursor_to_beginning_of_line(false); - } - KeyEvent { - code: KeyCode::Char('a'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_beginning_of_line(true); - } - - KeyEvent { - code: KeyCode::End, .. - } => { - self.move_cursor_to_end_of_line(false); - } - KeyEvent { - code: KeyCode::Char('e'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.move_cursor_to_end_of_line(true); - } - _o => { - #[cfg(feature = "debug-logs")] - tracing::debug!("Unhandled key event in TextArea: {:?}", _o); - } - } - } - - // ####### Input Functions ####### - pub fn delete_backward(&mut self, n: usize) { - if n == 0 || self.cursor_pos == 0 { - return; - } - let mut target = self.cursor_pos; - for _ in 0..n { - target = self.prev_atomic_boundary(target); - if target == 0 { - break; - } - } - self.replace_range(target..self.cursor_pos, ""); - } - - pub fn delete_forward(&mut self, n: usize) { - if n == 0 || self.cursor_pos >= self.text.len() { - return; - } - let mut target = self.cursor_pos; - for _ in 0..n { - target = self.next_atomic_boundary(target); - if target >= self.text.len() { - break; - } - } - self.replace_range(self.cursor_pos..target, ""); - } - - pub fn delete_backward_word(&mut self) { - let start = self.beginning_of_previous_word(); - self.kill_range(start..self.cursor_pos); - } - - /// Delete text to the right of the cursor using "word" semantics. - /// - /// Deletes from the current cursor position through the end of the next word as determined - /// by `end_of_next_word()`. Any whitespace (including newlines) between the cursor and that - /// word is included in the deletion. - pub fn delete_forward_word(&mut self) { - let end = self.end_of_next_word(); - if end > self.cursor_pos { - self.kill_range(self.cursor_pos..end); - } - } - - pub fn kill_to_end_of_line(&mut self) { - let eol = self.end_of_current_line(); - let range = if self.cursor_pos == eol { - if eol < self.text.len() { - Some(self.cursor_pos..eol + 1) - } else { - None - } - } else { - Some(self.cursor_pos..eol) - }; - - if let Some(range) = range { - self.kill_range(range); - } - } - - pub fn kill_to_beginning_of_line(&mut self) { - let bol = self.beginning_of_current_line(); - let range = if self.cursor_pos == bol { - if bol > 0 { Some(bol - 1..bol) } else { None } - } else { - Some(bol..self.cursor_pos) - }; - - if let Some(range) = range { - self.kill_range(range); - } - } - - pub fn yank(&mut self) { - if self.kill_buffer.is_empty() { - return; - } - let text = self.kill_buffer.clone(); - self.insert_str(&text); - } - - fn kill_range(&mut self, range: Range) { - let range = self.expand_range_to_element_boundaries(range); - if range.start >= range.end { - return; - } - - let removed = self.text[range.clone()].to_string(); - if removed.is_empty() { - return; - } - - self.kill_buffer = removed; - self.replace_range_raw(range, ""); - } - - /// Move the cursor left by a single grapheme cluster. - pub fn move_cursor_left(&mut self) { - self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); - self.preferred_col = None; - } - - /// Move the cursor right by a single grapheme cluster. - pub fn move_cursor_right(&mut self) { - self.cursor_pos = self.next_atomic_boundary(self.cursor_pos); - self.preferred_col = None; - } - - pub fn move_cursor_up(&mut self) { - // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. - if let Some((target_col, maybe_line)) = { - let cache_ref = self.wrap_cache.borrow(); - if let Some(cache) = cache_ref.as_ref() { - let lines = &cache.lines; - if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { - let cur_range = &lines[idx]; - let target_col = self - .preferred_col - .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); - if idx > 0 { - let prev = &lines[idx - 1]; - let line_start = prev.start; - let line_end = prev.end.saturating_sub(1); - Some((target_col, Some((line_start, line_end)))) - } else { - Some((target_col, None)) - } - } else { - None - } - } else { - None - } - } { - // We had wrapping info. Apply movement accordingly. - match maybe_line { - Some((line_start, line_end)) => { - if self.preferred_col.is_none() { - self.preferred_col = Some(target_col); - } - self.move_to_display_col_on_line(line_start, line_end, target_col); - return; - } - None => { - // Already at first visual line -> move to start - self.cursor_pos = 0; - self.preferred_col = None; - return; - } - } - } - - // Fallback to logical line navigation if we don't have wrapping info yet. - if let Some(prev_nl) = self.text[..self.cursor_pos].rfind('\n') { - let target_col = match self.preferred_col { - Some(c) => c, - None => { - let c = self.current_display_col(); - self.preferred_col = Some(c); - c - } - }; - let prev_line_start = self.text[..prev_nl].rfind('\n').map(|i| i + 1).unwrap_or(0); - let prev_line_end = prev_nl; - self.move_to_display_col_on_line(prev_line_start, prev_line_end, target_col); - } else { - self.cursor_pos = 0; - self.preferred_col = None; - } - } - - pub fn move_cursor_down(&mut self) { - // If we have a wrapping cache, prefer navigating across wrapped (visual) lines. - if let Some((target_col, move_to_last)) = { - let cache_ref = self.wrap_cache.borrow(); - if let Some(cache) = cache_ref.as_ref() { - let lines = &cache.lines; - if let Some(idx) = Self::wrapped_line_index_by_start(lines, self.cursor_pos) { - let cur_range = &lines[idx]; - let target_col = self - .preferred_col - .unwrap_or_else(|| self.text[cur_range.start..self.cursor_pos].width()); - if idx + 1 < lines.len() { - let next = &lines[idx + 1]; - let line_start = next.start; - let line_end = next.end.saturating_sub(1); - Some((target_col, Some((line_start, line_end)))) - } else { - Some((target_col, None)) - } - } else { - None - } - } else { - None - } - } { - match move_to_last { - Some((line_start, line_end)) => { - if self.preferred_col.is_none() { - self.preferred_col = Some(target_col); - } - self.move_to_display_col_on_line(line_start, line_end, target_col); - return; - } - None => { - // Already on last visual line -> move to end - self.cursor_pos = self.text.len(); - self.preferred_col = None; - return; - } - } - } - - // Fallback to logical line navigation if we don't have wrapping info yet. - let target_col = match self.preferred_col { - Some(c) => c, - None => { - let c = self.current_display_col(); - self.preferred_col = Some(c); - c - } - }; - if let Some(next_nl) = self.text[self.cursor_pos..] - .find('\n') - .map(|i| i + self.cursor_pos) - { - let next_line_start = next_nl + 1; - let next_line_end = self.text[next_line_start..] - .find('\n') - .map(|i| i + next_line_start) - .unwrap_or(self.text.len()); - self.move_to_display_col_on_line(next_line_start, next_line_end, target_col); - } else { - self.cursor_pos = self.text.len(); - self.preferred_col = None; - } - } - - pub fn move_cursor_to_beginning_of_line(&mut self, move_up_at_bol: bool) { - let bol = self.beginning_of_current_line(); - if move_up_at_bol && self.cursor_pos == bol { - self.set_cursor(self.beginning_of_line(self.cursor_pos.saturating_sub(1))); - } else { - self.set_cursor(bol); - } - self.preferred_col = None; - } - - pub fn move_cursor_to_end_of_line(&mut self, move_down_at_eol: bool) { - let eol = self.end_of_current_line(); - if move_down_at_eol && self.cursor_pos == eol { - let next_pos = (self.cursor_pos.saturating_add(1)).min(self.text.len()); - self.set_cursor(self.end_of_line(next_pos)); - } else { - self.set_cursor(eol); - } - } - - // ===== Text elements support ===== - - pub fn element_payloads(&self) -> Vec { - self.elements - .iter() - .filter_map(|e| self.text.get(e.range.clone()).map(str::to_string)) - .collect() - } - - pub fn element_payload_starting_at(&self, pos: usize) -> Option { - let pos = pos.min(self.text.len()); - let elem = self.elements.iter().find(|e| e.range.start == pos)?; - self.text.get(elem.range.clone()).map(str::to_string) - } - - /// Renames a single text element in-place, keeping it atomic. - /// - /// Use this when the element payload is an identifier (e.g. a placeholder) that must be - /// updated without converting the element back into normal text. - pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool { - let Some(idx) = self - .elements - .iter() - .position(|e| self.text.get(e.range.clone()) == Some(old)) - else { - return false; - }; - - let range = self.elements[idx].range.clone(); - let start = range.start; - let end = range.end; - if start > end || end > self.text.len() { - return false; - } - - let removed_len = end - start; - let inserted_len = new.len(); - let diff = inserted_len as isize - removed_len as isize; - - self.text.replace_range(range, new); - self.wrap_cache.replace(None); - self.preferred_col = None; - - // Update the modified element's range. - self.elements[idx].range = start..(start + inserted_len); - - // Shift element ranges that occur after the replaced element. - if diff != 0 { - for (j, e) in self.elements.iter_mut().enumerate() { - if j == idx { - continue; - } - if e.range.end <= start { - continue; - } - if e.range.start >= end { - e.range.start = ((e.range.start as isize) + diff) as usize; - e.range.end = ((e.range.end as isize) + diff) as usize; - continue; - } - - // Elements should not partially overlap each other; degrade gracefully by - // snapping anything intersecting the replaced range to the new bounds. - e.range.start = start.min(e.range.start); - e.range.end = (start + inserted_len).max(e.range.end.saturating_add_signed(diff)); - } - } - - // Update the cursor position to account for the edit. - self.cursor_pos = if self.cursor_pos < start { - self.cursor_pos - } else if self.cursor_pos <= end { - start + inserted_len - } else { - ((self.cursor_pos as isize) + diff) as usize - }; - self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); - - // Keep element ordering deterministic. - self.elements.sort_by_key(|e| e.range.start); - - true - } - - pub fn insert_element(&mut self, text: &str) { - let start = self.clamp_pos_for_insertion(self.cursor_pos); - self.insert_str_at(start, text); - let end = start + text.len(); - self.add_element(start..end); - // Place cursor at end of inserted element - self.set_cursor(end); - } - - fn add_element(&mut self, range: Range) { - let elem = TextElement { range }; - self.elements.push(elem); - self.elements.sort_by_key(|e| e.range.start); - } - - fn find_element_containing(&self, pos: usize) -> Option { - self.elements - .iter() - .position(|e| pos > e.range.start && pos < e.range.end) - } - - fn clamp_pos_to_char_boundary(&self, pos: usize) -> usize { - let pos = pos.min(self.text.len()); - if self.text.is_char_boundary(pos) { - return pos; - } - let mut prev = pos; - while prev > 0 && !self.text.is_char_boundary(prev) { - prev -= 1; - } - let mut next = pos; - while next < self.text.len() && !self.text.is_char_boundary(next) { - next += 1; - } - if pos.saturating_sub(prev) <= next.saturating_sub(pos) { - prev - } else { - next - } - } - - fn clamp_pos_to_nearest_boundary(&self, pos: usize) -> usize { - let pos = self.clamp_pos_to_char_boundary(pos); - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - let dist_start = pos.saturating_sub(e.range.start); - let dist_end = e.range.end.saturating_sub(pos); - if dist_start <= dist_end { - self.clamp_pos_to_char_boundary(e.range.start) - } else { - self.clamp_pos_to_char_boundary(e.range.end) - } - } else { - pos - } - } - - fn clamp_pos_for_insertion(&self, pos: usize) -> usize { - let pos = self.clamp_pos_to_char_boundary(pos); - // Do not allow inserting into the middle of an element - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - // Choose closest edge for insertion - let dist_start = pos.saturating_sub(e.range.start); - let dist_end = e.range.end.saturating_sub(pos); - if dist_start <= dist_end { - self.clamp_pos_to_char_boundary(e.range.start) - } else { - self.clamp_pos_to_char_boundary(e.range.end) - } - } else { - pos - } - } - - fn expand_range_to_element_boundaries(&self, mut range: Range) -> Range { - // Expand to include any intersecting elements fully - loop { - let mut changed = false; - for e in &self.elements { - if e.range.start < range.end && e.range.end > range.start { - let new_start = range.start.min(e.range.start); - let new_end = range.end.max(e.range.end); - if new_start != range.start || new_end != range.end { - range.start = new_start; - range.end = new_end; - changed = true; - } - } - } - if !changed { - break; - } - } - range - } - - fn shift_elements(&mut self, at: usize, removed: usize, inserted: usize) { - // Generic shift: for pure insert, removed = 0; for delete, inserted = 0. - let end = at + removed; - let diff = inserted as isize - removed as isize; - // Remove elements fully deleted by the operation and shift the rest - self.elements - .retain(|e| !(e.range.start >= at && e.range.end <= end)); - for e in &mut self.elements { - if e.range.end <= at { - // before edit - } else if e.range.start >= end { - // after edit - e.range.start = ((e.range.start as isize) + diff) as usize; - e.range.end = ((e.range.end as isize) + diff) as usize; - } else { - // Overlap with element but not fully contained (shouldn't happen when using - // element-aware replace, but degrade gracefully by snapping element to new bounds) - let new_start = at.min(e.range.start); - let new_end = at + inserted.max(e.range.end.saturating_sub(end)); - e.range.start = new_start; - e.range.end = new_end; - } - } - } - - fn update_elements_after_replace(&mut self, start: usize, end: usize, inserted_len: usize) { - self.shift_elements(start, end.saturating_sub(start), inserted_len); - } - - fn prev_atomic_boundary(&self, pos: usize) -> usize { - if pos == 0 { - return 0; - } - // If currently at an element end or inside, jump to start of that element. - if let Some(idx) = self - .elements - .iter() - .position(|e| pos > e.range.start && pos <= e.range.end) - { - return self.elements[idx].range.start; - } - let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); - match gc.prev_boundary(&self.text, 0) { - Ok(Some(b)) => { - if let Some(idx) = self.find_element_containing(b) { - self.elements[idx].range.start - } else { - b - } - } - Ok(None) => 0, - Err(_) => pos.saturating_sub(1), - } - } - - fn next_atomic_boundary(&self, pos: usize) -> usize { - if pos >= self.text.len() { - return self.text.len(); - } - // If currently at an element start or inside, jump to end of that element. - if let Some(idx) = self - .elements - .iter() - .position(|e| pos >= e.range.start && pos < e.range.end) - { - return self.elements[idx].range.end; - } - let mut gc = unicode_segmentation::GraphemeCursor::new(pos, self.text.len(), false); - match gc.next_boundary(&self.text, 0) { - Ok(Some(b)) => { - if let Some(idx) = self.find_element_containing(b) { - self.elements[idx].range.end - } else { - b - } - } - Ok(None) => self.text.len(), - Err(_) => pos.saturating_add(1), - } - } - - pub(crate) fn beginning_of_previous_word(&self) -> usize { - let prefix = &self.text[..self.cursor_pos]; - let Some((first_non_ws_idx, ch)) = prefix - .char_indices() - .rev() - .find(|&(_, ch)| !ch.is_whitespace()) - else { - return 0; - }; - let is_separator = is_word_separator(ch); - let mut start = first_non_ws_idx; - for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() { - if ch.is_whitespace() || is_word_separator(ch) != is_separator { - start = idx + ch.len_utf8(); - break; - } - start = idx; - } - self.adjust_pos_out_of_elements(start, true) - } - - pub(crate) fn end_of_next_word(&self) -> usize { - let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) - else { - return self.text.len(); - }; - let word_start = self.cursor_pos + first_non_ws; - let mut iter = self.text[word_start..].char_indices(); - let Some((_, first_ch)) = iter.next() else { - return word_start; - }; - let is_separator = is_word_separator(first_ch); - let mut end = self.text.len(); - for (idx, ch) in iter { - if ch.is_whitespace() || is_word_separator(ch) != is_separator { - end = word_start + idx; - break; - } - } - self.adjust_pos_out_of_elements(end, false) - } - - fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { - if let Some(idx) = self.find_element_containing(pos) { - let e = &self.elements[idx]; - if prefer_start { - e.range.start - } else { - e.range.end - } - } else { - pos - } - } - - #[expect(clippy::unwrap_used)] - fn wrapped_lines(&self, width: u16) -> Ref<'_, Vec>> { - // Ensure cache is ready (potentially mutably borrow, then drop) - { - let mut cache = self.wrap_cache.borrow_mut(); - let needs_recalc = match cache.as_ref() { - Some(c) => c.width != width, - None => true, - }; - if needs_recalc { - let lines = crate::wrapping::wrap_ranges( - &self.text, - Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - *cache = Some(WrapCache { width, lines }); - } - } - - let cache = self.wrap_cache.borrow(); - Ref::map(cache, |c| &c.as_ref().unwrap().lines) - } - - /// Calculate the scroll offset that should be used to satisfy the - /// invariants given the current area size and wrapped lines. - /// - /// - Cursor is always on screen. - /// - No scrolling if content fits in the area. - fn effective_scroll( - &self, - area_height: u16, - lines: &[Range], - current_scroll: u16, - ) -> u16 { - let total_lines = lines.len() as u16; - if area_height >= total_lines { - return 0; - } - - // Where is the cursor within wrapped lines? Prefer assigning boundary positions - // (where pos equals the start of a wrapped line) to that later line. - let cursor_line_idx = - Self::wrapped_line_index_by_start(lines, self.cursor_pos).unwrap_or(0) as u16; - - let max_scroll = total_lines.saturating_sub(area_height); - let mut scroll = current_scroll.min(max_scroll); - - // Ensure cursor is visible within [scroll, scroll + area_height) - if cursor_line_idx < scroll { - scroll = cursor_line_idx; - } else if cursor_line_idx >= scroll + area_height { - scroll = cursor_line_idx + 1 - area_height; - } - scroll - } -} - -impl WidgetRef for &TextArea { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let lines = self.wrapped_lines(area.width); - self.render_lines(area, buf, &lines, 0..lines.len()); - } -} - -impl StatefulWidgetRef for &TextArea { - type State = TextAreaState; - - fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let lines = self.wrapped_lines(area.width); - let scroll = self.effective_scroll(area.height, &lines, state.scroll); - state.scroll = scroll; - - let start = scroll as usize; - let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end); - } -} - -impl TextArea { - fn render_lines( - &self, - area: Rect, - buf: &mut Buffer, - lines: &[Range], - range: std::ops::Range, - ) { - for (row, idx) in range.enumerate() { - let r = &lines[idx]; - let y = area.y + row as u16; - let line_range = r.start..r.end - 1; - // Draw base line with default style. - buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); - - // Overlay styled segments for elements that intersect this line. - for elem in &self.elements { - // Compute overlap with displayed slice. - let overlap_start = elem.range.start.max(line_range.start); - let overlap_end = elem.range.end.min(line_range.end); - if overlap_start >= overlap_end { - continue; - } - let styled = &self.text[overlap_start..overlap_end]; - let x_off = self.text[line_range.start..overlap_start].width() as u16; - let style = Style::default().fg(Color::Cyan); - buf.set_string(area.x + x_off, y, styled, style); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - // crossterm types are intentionally not imported here to avoid unused warnings - use pretty_assertions::assert_eq; - use rand::prelude::*; - - fn rand_grapheme(rng: &mut rand::rngs::StdRng) -> String { - let r: u8 = rng.random_range(0..100); - match r { - 0..=4 => "\n".to_string(), - 5..=12 => " ".to_string(), - 13..=35 => (rng.random_range(b'a'..=b'z') as char).to_string(), - 36..=45 => (rng.random_range(b'A'..=b'Z') as char).to_string(), - 46..=52 => (rng.random_range(b'0'..=b'9') as char).to_string(), - 53..=65 => { - // Some emoji (wide graphemes) - let choices = ["👍", "😊", "🐍", "🚀", "🧪", "🌟"]; - choices[rng.random_range(0..choices.len())].to_string() - } - 66..=75 => { - // CJK wide characters - let choices = ["漢", "字", "測", "試", "你", "好", "界", "编", "码"]; - choices[rng.random_range(0..choices.len())].to_string() - } - 76..=85 => { - // Combining mark sequences - let base = ["e", "a", "o", "n", "u"][rng.random_range(0..5)]; - let marks = ["\u{0301}", "\u{0308}", "\u{0302}", "\u{0303}"]; - format!("{base}{}", marks[rng.random_range(0..marks.len())]) - } - 86..=92 => { - // Some non-latin single codepoints (Greek, Cyrillic, Hebrew) - let choices = ["Ω", "β", "Ж", "ю", "ש", "م", "ह"]; - choices[rng.random_range(0..choices.len())].to_string() - } - _ => { - // ZWJ sequences (single graphemes but multi-codepoint) - let choices = [ - "👩\u{200D}💻", // woman technologist - "👨\u{200D}💻", // man technologist - "🏳️\u{200D}🌈", // rainbow flag - ]; - choices[rng.random_range(0..choices.len())].to_string() - } - } - } - - fn ta_with(text: &str) -> TextArea { - let mut t = TextArea::new(); - t.insert_str(text); - t - } - - #[test] - fn insert_and_replace_update_cursor_and_text() { - // insert helpers - let mut t = ta_with("hello"); - t.set_cursor(5); - t.insert_str("!"); - assert_eq!(t.text(), "hello!"); - assert_eq!(t.cursor(), 6); - - t.insert_str_at(0, "X"); - assert_eq!(t.text(), "Xhello!"); - assert_eq!(t.cursor(), 7); - - // Insert after the cursor should not move it - t.set_cursor(1); - let end = t.text().len(); - t.insert_str_at(end, "Y"); - assert_eq!(t.text(), "Xhello!Y"); - assert_eq!(t.cursor(), 1); - - // replace_range cases - // 1) cursor before range - let mut t = ta_with("abcd"); - t.set_cursor(1); - t.replace_range(2..3, "Z"); - assert_eq!(t.text(), "abZd"); - assert_eq!(t.cursor(), 1); - - // 2) cursor inside range - let mut t = ta_with("abcd"); - t.set_cursor(2); - t.replace_range(1..3, "Q"); - assert_eq!(t.text(), "aQd"); - assert_eq!(t.cursor(), 2); - - // 3) cursor after range with shifted by diff - let mut t = ta_with("abcd"); - t.set_cursor(4); - t.replace_range(0..1, "AA"); - assert_eq!(t.text(), "AAbcd"); - assert_eq!(t.cursor(), 5); - } - - #[test] - fn insert_str_at_clamps_to_char_boundary() { - let mut t = TextArea::new(); - t.insert_str("你"); - t.set_cursor(0); - t.insert_str_at(1, "A"); - assert_eq!(t.text(), "A你"); - assert_eq!(t.cursor(), 1); - } - - #[test] - fn set_text_clamps_cursor_to_char_boundary() { - let mut t = TextArea::new(); - t.insert_str("abcd"); - t.set_cursor(1); - t.set_text("你"); - assert_eq!(t.cursor(), 0); - t.insert_str("a"); - assert_eq!(t.text(), "a你"); - } - - #[test] - fn delete_backward_and_forward_edges() { - let mut t = ta_with("abc"); - t.set_cursor(1); - t.delete_backward(1); - assert_eq!(t.text(), "bc"); - assert_eq!(t.cursor(), 0); - - // deleting backward at start is a no-op - t.set_cursor(0); - t.delete_backward(1); - assert_eq!(t.text(), "bc"); - assert_eq!(t.cursor(), 0); - - // forward delete removes next grapheme - t.set_cursor(1); - t.delete_forward(1); - assert_eq!(t.text(), "b"); - assert_eq!(t.cursor(), 1); - - // forward delete at end is a no-op - t.set_cursor(t.text().len()); - t.delete_forward(1); - assert_eq!(t.text(), "b"); - } - - #[test] - fn delete_backward_word_and_kill_line_variants() { - // delete backward word at end removes the whole previous word - let mut t = ta_with("hello world "); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 8); - - // From inside a word, delete from word start to cursor - let mut t = ta_with("foo bar"); - t.set_cursor(6); // inside "bar" (after 'a') - t.delete_backward_word(); - assert_eq!(t.text(), "foo r"); - assert_eq!(t.cursor(), 4); - - // From end, delete the last word only - let mut t = ta_with("foo bar"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "foo "); - assert_eq!(t.cursor(), 4); - - // kill_to_end_of_line when not at EOL - let mut t = ta_with("abc\ndef"); - t.set_cursor(1); // on first line, middle - t.kill_to_end_of_line(); - assert_eq!(t.text(), "a\ndef"); - assert_eq!(t.cursor(), 1); - - // kill_to_end_of_line when at EOL deletes newline - let mut t = ta_with("abc\ndef"); - t.set_cursor(3); // EOL of first line - t.kill_to_end_of_line(); - assert_eq!(t.text(), "abcdef"); - assert_eq!(t.cursor(), 3); - - // kill_to_beginning_of_line from middle of line - let mut t = ta_with("abc\ndef"); - t.set_cursor(5); // on second line, after 'e' - t.kill_to_beginning_of_line(); - assert_eq!(t.text(), "abc\nef"); - - // kill_to_beginning_of_line at beginning of non-first line removes the previous newline - let mut t = ta_with("abc\ndef"); - t.set_cursor(4); // beginning of second line - t.kill_to_beginning_of_line(); - assert_eq!(t.text(), "abcdef"); - assert_eq!(t.cursor(), 3); - } - - #[test] - fn delete_forward_word_variants() { - let mut t = ta_with("hello world "); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " world "); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with("hello world "); - t.set_cursor(1); - t.delete_forward_word(); - assert_eq!(t.text(), "h world "); - assert_eq!(t.cursor(), 1); - - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); - t.delete_forward_word(); - assert_eq!(t.text(), "hello world"); - assert_eq!(t.cursor(), t.text().len()); - - let mut t = ta_with("foo \nbar"); - t.set_cursor(3); - t.delete_forward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 3); - - let mut t = ta_with("foo\nbar"); - t.set_cursor(3); - t.delete_forward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 3); - - let mut t = ta_with("hello world "); - t.set_cursor(t.text().len() + 10); - t.delete_forward_word(); - assert_eq!(t.text(), "hello world "); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn delete_forward_word_handles_atomic_elements() { - let mut t = TextArea::new(); - t.insert_element(""); - t.insert_str(" tail"); - - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " tail"); - assert_eq!(t.cursor(), 0); - - let mut t = TextArea::new(); - t.insert_str(" "); - t.insert_element(""); - t.insert_str(" tail"); - - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " tail"); - assert_eq!(t.cursor(), 0); - - let mut t = TextArea::new(); - t.insert_str("prefix "); - t.insert_element(""); - t.insert_str(" tail"); - - // cursor in the middle of the element, delete_forward_word deletes the element - let elem_range = t.elements[0].range.clone(); - t.cursor_pos = elem_range.start + (elem_range.len() / 2); - t.delete_forward_word(); - assert_eq!(t.text(), "prefix tail"); - assert_eq!(t.cursor(), elem_range.start); - } - - #[test] - fn delete_backward_word_respects_word_separators() { - let mut t = ta_with("path/to/file"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "path/to/"); - assert_eq!(t.cursor(), t.text().len()); - - t.delete_backward_word(); - assert_eq!(t.text(), "path/to"); - assert_eq!(t.cursor(), t.text().len()); - - let mut t = ta_with("foo/ "); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 3); - - let mut t = ta_with("foo /"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "foo "); - assert_eq!(t.cursor(), 4); - } - - #[test] - fn delete_forward_word_respects_word_separators() { - let mut t = ta_with("path/to/file"); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), "/to/file"); - assert_eq!(t.cursor(), 0); - - t.delete_forward_word(); - assert_eq!(t.text(), "to/file"); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with("/ foo"); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), " foo"); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with(" /foo"); - t.set_cursor(0); - t.delete_forward_word(); - assert_eq!(t.text(), "foo"); - assert_eq!(t.cursor(), 0); - } - - #[test] - fn yank_restores_last_kill() { - let mut t = ta_with("hello"); - t.set_cursor(0); - t.kill_to_end_of_line(); - assert_eq!(t.text(), ""); - assert_eq!(t.cursor(), 0); - - t.yank(); - assert_eq!(t.text(), "hello"); - assert_eq!(t.cursor(), 5); - - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); - t.delete_backward_word(); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 6); - - t.yank(); - assert_eq!(t.text(), "hello world"); - assert_eq!(t.cursor(), 11); - - let mut t = ta_with("hello"); - t.set_cursor(5); - t.kill_to_beginning_of_line(); - assert_eq!(t.text(), ""); - assert_eq!(t.cursor(), 0); - - t.yank(); - assert_eq!(t.text(), "hello"); - assert_eq!(t.cursor(), 5); - } - - #[test] - fn cursor_left_and_right_handle_graphemes() { - let mut t = ta_with("a👍b"); - t.set_cursor(t.text().len()); - - t.move_cursor_left(); // before 'b' - let after_first_left = t.cursor(); - t.move_cursor_left(); // before '👍' - let after_second_left = t.cursor(); - t.move_cursor_left(); // before 'a' - let after_third_left = t.cursor(); - - assert!(after_first_left < t.text().len()); - assert!(after_second_left < after_first_left); - assert!(after_third_left < after_second_left); - - // Move right back to end safely - t.move_cursor_right(); - t.move_cursor_right(); - t.move_cursor_right(); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn control_b_and_f_move_cursor() { - let mut t = ta_with("abcd"); - t.set_cursor(1); - - t.input(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)); - assert_eq!(t.cursor(), 2); - - t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); - assert_eq!(t.cursor(), 1); - } - - #[test] - fn control_b_f_fallback_control_chars_move_cursor() { - let mut t = ta_with("abcd"); - t.set_cursor(2); - - // Simulate terminals that send C0 control chars without CONTROL modifier. - // ^B (U+0002) should move left - t.input(KeyEvent::new(KeyCode::Char('\u{0002}'), KeyModifiers::NONE)); - assert_eq!(t.cursor(), 1); - - // ^F (U+0006) should move right - t.input(KeyEvent::new(KeyCode::Char('\u{0006}'), KeyModifiers::NONE)); - assert_eq!(t.cursor(), 2); - } - - #[test] - fn delete_backward_word_alt_keys() { - // Test the custom Alt+Ctrl+h binding - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); // cursor at the end - t.input(KeyEvent::new( - KeyCode::Char('h'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 6); - - // Test the standard Alt+Backspace binding - let mut t = ta_with("hello world"); - t.set_cursor(t.text().len()); // cursor at the end - t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); - assert_eq!(t.text(), "hello "); - assert_eq!(t.cursor(), 6); - } - - #[test] - fn delete_backward_word_handles_narrow_no_break_space() { - let mut t = ta_with("32\u{202F}AM"); - t.set_cursor(t.text().len()); - t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); - pretty_assertions::assert_eq!(t.text(), "32\u{202F}"); - pretty_assertions::assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn delete_forward_word_with_without_alt_modifier() { - let mut t = ta_with("hello world"); - t.set_cursor(0); - t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT)); - assert_eq!(t.text(), " world"); - assert_eq!(t.cursor(), 0); - - let mut t = ta_with("hello"); - t.set_cursor(0); - t.input(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); - assert_eq!(t.text(), "ello"); - assert_eq!(t.cursor(), 0); - } - - #[test] - fn control_h_backspace() { - // Test Ctrl+H as backspace - let mut t = ta_with("12345"); - t.set_cursor(3); // cursor after '3' - t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); - assert_eq!(t.text(), "1245"); - assert_eq!(t.cursor(), 2); - - // Test Ctrl+H at beginning (should be no-op) - t.set_cursor(0); - t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); - assert_eq!(t.text(), "1245"); - assert_eq!(t.cursor(), 0); - - // Test Ctrl+H at end - t.set_cursor(t.text().len()); - t.input(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); - assert_eq!(t.text(), "124"); - assert_eq!(t.cursor(), 3); - } - - #[cfg_attr(not(windows), ignore = "AltGr modifier only applies on Windows")] - #[test] - fn altgr_ctrl_alt_char_inserts_literal() { - let mut t = ta_with(""); - t.input(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL | KeyModifiers::ALT, - )); - assert_eq!(t.text(), "c"); - assert_eq!(t.cursor(), 1); - } - - #[test] - fn cursor_vertical_movement_across_lines_and_bounds() { - let mut t = ta_with("short\nloooooooooong\nmid"); - // Place cursor on second line, column 5 - let second_line_start = 6; // after first '\n' - t.set_cursor(second_line_start + 5); - - // Move up: target column preserved, clamped by line length - t.move_cursor_up(); - assert_eq!(t.cursor(), 5); // first line has len 5 - - // Move up again goes to start of text - t.move_cursor_up(); - assert_eq!(t.cursor(), 0); - - // Move down: from start to target col tracked - t.move_cursor_down(); - // On first move down, we should land on second line, at col 0 (target col remembered as 0) - let pos_after_down = t.cursor(); - assert!(pos_after_down >= second_line_start); - - // Move down again to third line; clamp to its length - t.move_cursor_down(); - let third_line_start = t.text().find("mid").unwrap(); - let third_line_end = third_line_start + 3; - assert!(t.cursor() >= third_line_start && t.cursor() <= third_line_end); - - // Moving down at last line jumps to end - t.move_cursor_down(); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn home_end_and_emacs_style_home_end() { - let mut t = ta_with("one\ntwo\nthree"); - // Position at middle of second line - let second_line_start = t.text().find("two").unwrap(); - t.set_cursor(second_line_start + 1); - - t.move_cursor_to_beginning_of_line(false); - assert_eq!(t.cursor(), second_line_start); - - // Ctrl-A behavior: if at BOL, go to beginning of previous line - t.move_cursor_to_beginning_of_line(true); - assert_eq!(t.cursor(), 0); // beginning of first line - - // Move to EOL of first line - t.move_cursor_to_end_of_line(false); - assert_eq!(t.cursor(), 3); - - // Ctrl-E: if at EOL, go to end of next line - t.move_cursor_to_end_of_line(true); - // end of second line ("two") is right before its '\n' - let end_second_nl = t.text().find("\nthree").unwrap(); - assert_eq!(t.cursor(), end_second_nl); - } - - #[test] - fn end_of_line_or_down_at_end_of_text() { - let mut t = ta_with("one\ntwo"); - // Place cursor at absolute end of the text - t.set_cursor(t.text().len()); - // Should remain at end without panicking - t.move_cursor_to_end_of_line(true); - assert_eq!(t.cursor(), t.text().len()); - - // Also verify behavior when at EOL of a non-final line: - let eol_first_line = 3; // index of '\n' in "one\ntwo" - t.set_cursor(eol_first_line); - t.move_cursor_to_end_of_line(true); - assert_eq!(t.cursor(), t.text().len()); // moves to end of next (last) line - } - - #[test] - fn word_navigation_helpers() { - let t = ta_with(" alpha beta gamma"); - let mut t = t; // make mutable for set_cursor - // Put cursor after "alpha" - let after_alpha = t.text().find("alpha").unwrap() + "alpha".len(); - t.set_cursor(after_alpha); - assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces - - // Put cursor at start of beta - let beta_start = t.text().find("beta").unwrap(); - t.set_cursor(beta_start); - assert_eq!(t.end_of_next_word(), beta_start + "beta".len()); - - // If at end, end_of_next_word returns len - t.set_cursor(t.text().len()); - assert_eq!(t.end_of_next_word(), t.text().len()); - } - - #[test] - fn wrapping_and_cursor_positions() { - let mut t = ta_with("hello world here"); - let area = Rect::new(0, 0, 6, 10); // width 6 -> wraps words - // desired height counts wrapped lines - assert!(t.desired_height(area.width) >= 3); - - // Place cursor in "world" - let world_start = t.text().find("world").unwrap(); - t.set_cursor(world_start + 3); - let (_x, y) = t.cursor_pos(area).unwrap(); - assert_eq!(y, 1); // world should be on second wrapped line - - // With state and small height, cursor is mapped onto visible row - let mut state = TextAreaState::default(); - let small_area = Rect::new(0, 0, 6, 1); - // First call: cursor not visible -> effective scroll ensures it is - let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); - assert_eq!(y, 0); - - // Render with state to update actual scroll value - let mut buf = Buffer::empty(small_area); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), small_area, &mut buf, &mut state); - // After render, state.scroll should be adjusted so cursor row fits - let effective_lines = t.desired_height(small_area.width); - assert!(state.scroll < effective_lines); - } - - #[test] - fn cursor_pos_with_state_basic_and_scroll_behaviors() { - // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. - let mut t = ta_with("hello world"); - t.set_cursor(3); - let area = Rect::new(2, 5, 20, 3); - // Even if an absurd scroll is provided, when content fits the area the - // effective scroll is 0 and the cursor position matches cursor_pos. - let bad_state = TextAreaState { scroll: 999 }; - let (x1, y1) = t.cursor_pos(area).unwrap(); - let (x2, y2) = t.cursor_pos_with_state(area, bad_state).unwrap(); - assert_eq!((x2, y2), (x1, y1)); - - // Case 2: Cursor below the current window — y should be clamped to the - // bottom row (area.height - 1) after adjusting effective scroll. - let mut t = ta_with("one two three four five six"); - // Force wrapping to many visual lines. - let wrap_width = 4; - let _ = t.desired_height(wrap_width); - // Put cursor somewhere near the end so it's definitely below the first window. - t.set_cursor(t.text().len().saturating_sub(2)); - let small_area = Rect::new(0, 0, wrap_width, 2); - let state = TextAreaState { scroll: 0 }; - let (_x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); - assert_eq!(y, small_area.y + small_area.height - 1); - - // Case 3: Cursor above the current window — y should be top row (0) - // when the provided scroll is too large. - let mut t = ta_with("alpha beta gamma delta epsilon zeta"); - let wrap_width = 5; - let lines = t.desired_height(wrap_width); - // Place cursor near start so an excessive scroll moves it to top row. - t.set_cursor(1); - let area = Rect::new(0, 0, wrap_width, 3); - let state = TextAreaState { - scroll: lines.saturating_mul(2), - }; - let (_x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!(y, area.y); - } - - #[test] - fn wrapped_navigation_across_visual_lines() { - let mut t = ta_with("abcdefghij"); - // Force wrapping at width 4: lines -> ["abcd", "efgh", "ij"] - let _ = t.desired_height(4); - - // From the very start, moving down should go to the start of the next wrapped line (index 4) - t.set_cursor(0); - t.move_cursor_down(); - assert_eq!(t.cursor(), 4); - - // Cursor at boundary index 4 should be displayed at start of second wrapped line - t.set_cursor(4); - let area = Rect::new(0, 0, 4, 10); - let (x, y) = t.cursor_pos(area).unwrap(); - assert_eq!((x, y), (0, 1)); - - // With state and small height, cursor should be visible at row 0, col 0 - let small_area = Rect::new(0, 0, 4, 1); - let state = TextAreaState::default(); - let (x, y) = t.cursor_pos_with_state(small_area, state).unwrap(); - assert_eq!((x, y), (0, 0)); - - // Place cursor in the middle of the second wrapped line ("efgh"), at 'g' - t.set_cursor(6); - // Move up should go to same column on previous wrapped line -> index 2 ('c') - t.move_cursor_up(); - assert_eq!(t.cursor(), 2); - - // Move down should return to same position on the next wrapped line -> back to index 6 ('g') - t.move_cursor_down(); - assert_eq!(t.cursor(), 6); - - // Move down again should go to third wrapped line. Target col is 2, but the line has len 2 -> clamp to end - t.move_cursor_down(); - assert_eq!(t.cursor(), t.text().len()); - } - - #[test] - fn cursor_pos_with_state_after_movements() { - let mut t = ta_with("abcdefghij"); - // Wrap width 4 -> visual lines: abcd | efgh | ij - let _ = t.desired_height(4); - let area = Rect::new(0, 0, 4, 2); - let mut state = TextAreaState::default(); - let mut buf = Buffer::empty(area); - - // Start at beginning - t.set_cursor(0); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 0)); - - // Move down to second visual line; should be at bottom row (row 1) within 2-line viewport - t.move_cursor_down(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 1)); - - // Move down to third visual line; viewport scrolls and keeps cursor on bottom row - t.move_cursor_down(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 1)); - - // Move up to second visual line; with current scroll, it appears on top row - t.move_cursor_up(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x, y) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x, y), (0, 0)); - - // Column preservation across moves: set to col 2 on first line, move down - t.set_cursor(2); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x0, y0) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x0, y0), (2, 0)); - t.move_cursor_down(); - ratatui::widgets::StatefulWidgetRef::render_ref(&(&t), area, &mut buf, &mut state); - let (x1, y1) = t.cursor_pos_with_state(area, state).unwrap(); - assert_eq!((x1, y1), (2, 1)); - } - - #[test] - fn wrapped_navigation_with_newlines_and_spaces() { - // Include spaces and an explicit newline to exercise boundaries - let mut t = ta_with("word1 word2\nword3"); - // Width 6 will wrap "word1 " and then "word2" before the newline - let _ = t.desired_height(6); - - // Put cursor on the second wrapped line before the newline, at column 1 of "word2" - let start_word2 = t.text().find("word2").unwrap(); - t.set_cursor(start_word2 + 1); - - // Up should go to first wrapped line, column 1 -> index 1 - t.move_cursor_up(); - assert_eq!(t.cursor(), 1); - - // Down should return to the same visual column on "word2" - t.move_cursor_down(); - assert_eq!(t.cursor(), start_word2 + 1); - - // Down again should cross the logical newline to the next visual line ("word3"), clamped to its length if needed - t.move_cursor_down(); - let start_word3 = t.text().find("word3").unwrap(); - assert!(t.cursor() >= start_word3 && t.cursor() <= start_word3 + "word3".len()); - } - - #[test] - fn wrapped_navigation_with_wide_graphemes() { - // Four thumbs up, each of display width 2, with width 3 to force wrapping inside grapheme boundaries - let mut t = ta_with("👍👍👍👍"); - let _ = t.desired_height(3); - - // Put cursor after the second emoji (which should be on first wrapped line) - t.set_cursor("👍👍".len()); - - // Move down should go to the start of the next wrapped line (same column preserved but clamped) - t.move_cursor_down(); - // We expect to land somewhere within the third emoji or at the start of it - let pos_after_down = t.cursor(); - assert!(pos_after_down >= "👍👍".len()); - - // Moving up should take us back to the original position - t.move_cursor_up(); - assert_eq!(t.cursor(), "👍👍".len()); - } - - #[test] - fn fuzz_textarea_randomized() { - // Deterministic seed for reproducibility - // Seed the RNG based on the current day in Pacific Time (PST/PDT). This - // keeps the fuzz test deterministic within a day while still varying - // day-to-day to improve coverage. - let pst_today_seed: u64 = (chrono::Utc::now() - chrono::Duration::hours(8)) - .date_naive() - .and_hms_opt(0, 0, 0) - .unwrap() - .and_utc() - .timestamp() as u64; - let mut rng = rand::rngs::StdRng::seed_from_u64(pst_today_seed); - - for _case in 0..500 { - let mut ta = TextArea::new(); - let mut state = TextAreaState::default(); - // Track element payloads we insert. Payloads use characters '[' and ']' which - // are not produced by rand_grapheme(), avoiding accidental collisions. - let mut elem_texts: Vec = Vec::new(); - let mut next_elem_id: usize = 0; - // Start with a random base string - let base_len = rng.random_range(0..30); - let mut base = String::new(); - for _ in 0..base_len { - base.push_str(&rand_grapheme(&mut rng)); - } - ta.set_text(&base); - // Choose a valid char boundary for initial cursor - let mut boundaries: Vec = vec![0]; - boundaries.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); - boundaries.push(ta.text().len()); - let init = boundaries[rng.random_range(0..boundaries.len())]; - ta.set_cursor(init); - - let mut width: u16 = rng.random_range(1..=12); - let mut height: u16 = rng.random_range(1..=4); - - for _step in 0..60 { - // Mostly stable width/height, occasionally change - if rng.random_bool(0.1) { - width = rng.random_range(1..=12); - } - if rng.random_bool(0.1) { - height = rng.random_range(1..=4); - } - - // Pick an operation - match rng.random_range(0..18) { - 0 => { - // insert small random string at cursor - let len = rng.random_range(0..6); - let mut s = String::new(); - for _ in 0..len { - s.push_str(&rand_grapheme(&mut rng)); - } - ta.insert_str(&s); - } - 1 => { - // replace_range with small random slice - let mut b: Vec = vec![0]; - b.extend(ta.text().char_indices().map(|(i, _)| i).skip(1)); - b.push(ta.text().len()); - let i1 = rng.random_range(0..b.len()); - let i2 = rng.random_range(0..b.len()); - let (start, end) = if b[i1] <= b[i2] { - (b[i1], b[i2]) - } else { - (b[i2], b[i1]) - }; - let insert_len = rng.random_range(0..=4); - let mut s = String::new(); - for _ in 0..insert_len { - s.push_str(&rand_grapheme(&mut rng)); - } - let before = ta.text().len(); - // If the chosen range intersects an element, replace_range will expand to - // element boundaries, so the naive size delta assertion does not hold. - let intersects_element = elem_texts.iter().any(|payload| { - if let Some(pstart) = ta.text().find(payload) { - let pend = pstart + payload.len(); - pstart < end && pend > start - } else { - false - } - }); - ta.replace_range(start..end, &s); - if !intersects_element { - let after = ta.text().len(); - assert_eq!( - after as isize, - before as isize + (s.len() as isize) - ((end - start) as isize) - ); - } - } - 2 => ta.delete_backward(rng.random_range(0..=3)), - 3 => ta.delete_forward(rng.random_range(0..=3)), - 4 => ta.delete_backward_word(), - 5 => ta.kill_to_beginning_of_line(), - 6 => ta.kill_to_end_of_line(), - 7 => ta.move_cursor_left(), - 8 => ta.move_cursor_right(), - 9 => ta.move_cursor_up(), - 10 => ta.move_cursor_down(), - 11 => ta.move_cursor_to_beginning_of_line(true), - 12 => ta.move_cursor_to_end_of_line(true), - 13 => { - // Insert an element with a unique sentinel payload - let payload = - format!("[[EL#{}:{}]]", next_elem_id, rng.random_range(1000..9999)); - next_elem_id += 1; - ta.insert_element(&payload); - elem_texts.push(payload); - } - 14 => { - // Try inserting inside an existing element (should clamp to boundary) - if let Some(payload) = elem_texts.choose(&mut rng).cloned() - && let Some(start) = ta.text().find(&payload) - { - let end = start + payload.len(); - if end - start > 2 { - let pos = rng.random_range(start + 1..end - 1); - let ins = rand_grapheme(&mut rng); - ta.insert_str_at(pos, &ins); - } - } - } - 15 => { - // Replace a range that intersects an element -> whole element should be replaced - if let Some(payload) = elem_texts.choose(&mut rng).cloned() - && let Some(start) = ta.text().find(&payload) - { - let end = start + payload.len(); - // Create an intersecting range [start-δ, end-δ2) - let mut s = start.saturating_sub(rng.random_range(0..=2)); - let mut e = (end + rng.random_range(0..=2)).min(ta.text().len()); - // Align to char boundaries to satisfy String::replace_range contract - let txt = ta.text(); - while s > 0 && !txt.is_char_boundary(s) { - s -= 1; - } - while e < txt.len() && !txt.is_char_boundary(e) { - e += 1; - } - if s < e { - // Small replacement text - let mut srep = String::new(); - for _ in 0..rng.random_range(0..=2) { - srep.push_str(&rand_grapheme(&mut rng)); - } - ta.replace_range(s..e, &srep); - } - } - } - 16 => { - // Try setting the cursor to a position inside an element; it should clamp out - if let Some(payload) = elem_texts.choose(&mut rng).cloned() - && let Some(start) = ta.text().find(&payload) - { - let end = start + payload.len(); - if end - start > 2 { - let pos = rng.random_range(start + 1..end - 1); - ta.set_cursor(pos); - } - } - } - _ => { - // Jump to word boundaries - if rng.random_bool(0.5) { - let p = ta.beginning_of_previous_word(); - ta.set_cursor(p); - } else { - let p = ta.end_of_next_word(); - ta.set_cursor(p); - } - } - } - - // Sanity invariants - assert!(ta.cursor() <= ta.text().len()); - - // Element invariants - for payload in &elem_texts { - if let Some(start) = ta.text().find(payload) { - let end = start + payload.len(); - // 1) Text inside elements matches the initially set payload - assert_eq!(&ta.text()[start..end], payload); - // 2) Cursor is never strictly inside an element - let c = ta.cursor(); - assert!( - c <= start || c >= end, - "cursor inside element: {start}..{end} at {c}" - ); - } - } - - // Render and compute cursor positions; ensure they are in-bounds and do not panic - let area = Rect::new(0, 0, width, height); - // Stateless render into an area tall enough for all wrapped lines - let total_lines = ta.desired_height(width); - let full_area = Rect::new(0, 0, width, total_lines.max(1)); - let mut buf = Buffer::empty(full_area); - ratatui::widgets::WidgetRef::render_ref(&(&ta), full_area, &mut buf); - - // cursor_pos: x must be within width when present - let _ = ta.cursor_pos(area); - - // cursor_pos_with_state: always within viewport rows - let (_x, _y) = ta - .cursor_pos_with_state(area, state) - .unwrap_or((area.x, area.y)); - - // Stateful render should not panic, and updates scroll - let mut sbuf = Buffer::empty(area); - ratatui::widgets::StatefulWidgetRef::render_ref( - &(&ta), - area, - &mut sbuf, - &mut state, - ); - - // After wrapping, desired height equals the number of lines we would render without scroll - let total_lines = total_lines as usize; - // state.scroll must not exceed total_lines when content fits within area height - if (height as usize) >= total_lines { - assert_eq!(state.scroll, 0); - } - } - } - } -} diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs deleted file mode 100644 index 1eb4d8c67b..0000000000 --- a/codex-rs/tui2/src/chatwidget.rs +++ /dev/null @@ -1,4636 +0,0 @@ -//! The main Codex TUI chat surface. -//! -//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering -//! for both the main viewport and overlay UIs. -//! -//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active -//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a -//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a -//! cached, render-only live tail derived from the current active cell so in-flight tool calls are -//! visible immediately. -//! -//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail -//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The -//! cache key is designed to change when the active cell mutates in place or when its transcript -//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on -//! every draw. -//! -//! The bottom pane exposes a single "task running" indicator that drives the spinner and interrupt -//! hints. This module treats that indicator as derived UI-busy state: it is set while an agent turn -//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked -//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via -//! `update_task_running_state`. -use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use codex_app_server_protocol::AuthMode; -use codex_backend_client::Client as BackendClient; -use codex_chatgpt::connectors; -use codex_core::config::Config; -use codex_core::config::ConstraintResult; -use codex_core::config::types::Notifications; -use codex_core::features::Feature; -use codex_core::git_info::current_branch_name; -use codex_core::git_info::local_git_branches; -use codex_core::models_manager::manager::ModelsManager; -use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::AgentReasoningRawContentDeltaEvent; -use codex_core::protocol::AgentReasoningRawContentEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::DeprecationNoticeEvent; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandOutputDeltaEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::ListCustomPromptsResponseEvent; -use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::McpListToolsResponseEvent; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillsListEntry; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TerminalInteractionEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnAbortReason; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnDiffEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::UserMessageEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; -use codex_core::protocol::WebSearchBeginEvent; -use codex_core::protocol::WebSearchEndEvent; -use codex_core::skills::model::SkillInterface; -use codex_core::skills::model::SkillMetadata; -use codex_protocol::ThreadId; -use codex_protocol::account::PlanType; -use codex_protocol::approvals::ElicitationRequestEvent; -use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::user_input::UserInput; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -use crossterm::event::KeyModifiers; -use rand::Rng; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use tokio::sync::mpsc::UnboundedSender; -use tokio::task::JoinHandle; -use tracing::debug; - -use crate::app_event::AppEvent; -use crate::app_event::ConnectorsSnapshot; -use crate::app_event::ExitMode; -#[cfg(target_os = "windows")] -use crate::app_event::WindowsSandboxEnableMode; -use crate::app_event::WindowsSandboxFallbackReason; -use crate::app_event_sender::AppEventSender; -use crate::bottom_pane::ApprovalRequest; -use crate::bottom_pane::BottomPane; -use crate::bottom_pane::BottomPaneParams; -use crate::bottom_pane::CancellationEvent; -use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; -use crate::bottom_pane::InputResult; -use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT; -use crate::bottom_pane::SelectionAction; -use crate::bottom_pane::SelectionItem; -use crate::bottom_pane::SelectionViewParams; -use crate::bottom_pane::custom_prompt_view::CustomPromptView; -use crate::bottom_pane::popup_consts::standard_popup_hint_line; -use crate::clipboard_paste::paste_image_to_temp_png; -use crate::collab; -use crate::collaboration_modes; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::ExecCell; -use crate::exec_cell::new_active_exec_command; -use crate::get_git_diff::get_git_diff; -use crate::history_cell; -use crate::history_cell::AgentMessageCell; -use crate::history_cell::HistoryCell; -use crate::history_cell::McpToolCallCell; -use crate::history_cell::PlainHistoryCell; -use crate::key_hint; -use crate::key_hint::KeyBinding; -use crate::markdown::append_markdown; -use crate::render::Insets; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::FlexRenderable; -use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableExt; -use crate::render::renderable::RenderableItem; -use crate::slash_command::SlashCommand; -use crate::status::RateLimitSnapshotDisplay; -use crate::text_formatting::truncate_text; -use crate::tui::FrameRequester; -mod interrupts; -use self::interrupts::InterruptManager; -mod agent; -use self::agent::spawn_agent; -use self::agent::spawn_agent_from_existing; -mod session_header; -use self::session_header::SessionHeader; -use crate::streaming::controller::StreamController; -use crate::version::CODEX_CLI_VERSION; -use std::path::Path; - -use chrono::Local; -use codex_common::approval_presets::ApprovalPreset; -use codex_common::approval_presets::builtin_approval_presets; -use codex_core::AuthManager; -use codex_core::CodexAuth; -use codex_core::ThreadManager; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; -use codex_file_search::FileMatch; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::UpdatePlanArgs; -use strum::IntoEnumIterator; - -const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; -const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; -// Track information about an in-flight exec command. -struct RunningCommand { - command: Vec, - parsed_cmd: Vec, - source: ExecCommandSource, -} - -struct UnifiedExecWaitState { - command_display: String, -} - -impl UnifiedExecWaitState { - fn new(command_display: String) -> Self { - Self { command_display } - } - - fn is_duplicate(&self, command_display: &str) -> bool { - self.command_display == command_display - } -} - -const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; -const NUDGE_MODEL_SLUG: &str = "gpt-5.1-codex-mini"; -const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; -const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; - -#[derive(Default)] -struct RateLimitWarningState { - secondary_index: usize, - primary_index: usize, -} - -impl RateLimitWarningState { - fn take_warnings( - &mut self, - secondary_used_percent: Option, - secondary_window_minutes: Option, - primary_used_percent: Option, - primary_window_minutes: Option, - ) -> Vec { - let reached_secondary_cap = - matches!(secondary_used_percent, Some(percent) if percent == 100.0); - let reached_primary_cap = matches!(primary_used_percent, Some(percent) if percent == 100.0); - if reached_secondary_cap || reached_primary_cap { - return Vec::new(); - } - - let mut warnings = Vec::new(); - - if let Some(secondary_used_percent) = secondary_used_percent { - let mut highest_secondary: Option = None; - while self.secondary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && secondary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index] - { - highest_secondary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.secondary_index]); - self.secondary_index += 1; - } - if let Some(threshold) = highest_secondary { - let limit_label = secondary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "weekly".to_string()); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." - )); - } - } - - if let Some(primary_used_percent) = primary_used_percent { - let mut highest_primary: Option = None; - while self.primary_index < RATE_LIMIT_WARNING_THRESHOLDS.len() - && primary_used_percent >= RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index] - { - highest_primary = Some(RATE_LIMIT_WARNING_THRESHOLDS[self.primary_index]); - self.primary_index += 1; - } - if let Some(threshold) = highest_primary { - let limit_label = primary_window_minutes - .map(get_limits_duration) - .unwrap_or_else(|| "5h".to_string()); - let remaining_percent = 100.0 - threshold; - warnings.push(format!( - "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." - )); - } - } - - warnings - } -} - -pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { - const MINUTES_PER_HOUR: i64 = 60; - const MINUTES_PER_DAY: i64 = 24 * MINUTES_PER_HOUR; - const MINUTES_PER_WEEK: i64 = 7 * MINUTES_PER_DAY; - const MINUTES_PER_MONTH: i64 = 30 * MINUTES_PER_DAY; - const ROUNDING_BIAS_MINUTES: i64 = 3; - - let windows_minutes = windows_minutes.max(0); - - if windows_minutes <= MINUTES_PER_DAY.saturating_add(ROUNDING_BIAS_MINUTES) { - let adjusted = windows_minutes.saturating_add(ROUNDING_BIAS_MINUTES); - let hours = std::cmp::max(1, adjusted / MINUTES_PER_HOUR); - format!("{hours}h") - } else if windows_minutes <= MINUTES_PER_WEEK.saturating_add(ROUNDING_BIAS_MINUTES) { - "weekly".to_string() - } else if windows_minutes <= MINUTES_PER_MONTH.saturating_add(ROUNDING_BIAS_MINUTES) { - "monthly".to_string() - } else { - "annual".to_string() - } -} - -/// Common initialization parameters shared by all `ChatWidget` constructors. -pub(crate) struct ChatWidgetInit { - pub(crate) config: Config, - pub(crate) frame_requester: FrameRequester, - pub(crate) app_event_tx: AppEventSender, - pub(crate) initial_prompt: Option, - pub(crate) initial_images: Vec, - pub(crate) enhanced_keys_supported: bool, - pub(crate) auth_manager: Arc, - pub(crate) models_manager: Arc, - pub(crate) feedback: codex_feedback::CodexFeedback, - pub(crate) is_first_run: bool, - pub(crate) model: Option, -} - -#[derive(Default)] -enum RateLimitSwitchPromptState { - #[default] - Idle, - Pending, - Shown, -} - -#[derive(Debug, Clone, Default)] -enum ConnectorsCacheState { - #[default] - Uninitialized, - Loading, - Ready(ConnectorsSnapshot), - Failed(String), -} - -type CollaborationModeSelection = collaboration_modes::Selection; - -/// Maintains the per-session UI state and interaction state machines for the chat screen. -/// -/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming -/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user -/// intent (`Op` submissions and `AppEvent` requests). -/// -/// It is not responsible for running the agent itself; it reflects progress by updating UI state -/// and by sending requests back to codex-core. -/// -/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing -/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting -/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit. -pub(crate) struct ChatWidget { - app_event_tx: AppEventSender, - codex_op_tx: UnboundedSender, - bottom_pane: BottomPane, - active_cell: Option>, - /// Monotonic-ish counter used to invalidate transcript overlay caching. - /// - /// The transcript overlay appends a cached "live tail" for the current active cell. Most - /// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer - /// identity alone is not a good cache key. - /// - /// Callers bump this whenever the active cell's transcript output could change without - /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision - /// where the overlay may briefly treat new tail content as already cached. - active_cell_revision: u64, - config: Config, - model: Option, - /// Current UI selection for collaboration modes. - /// - /// This selection is only meaningful when `Feature::CollaborationModes` is enabled; when the - /// feature is disabled, the value is effectively inert. - collaboration_mode: CollaborationModeSelection, - auth_manager: Arc, - models_manager: Arc, - session_header: SessionHeader, - initial_user_message: Option, - token_info: Option, - rate_limit_snapshot: Option, - plan_type: Option, - rate_limit_warnings: RateLimitWarningState, - rate_limit_switch_prompt: RateLimitSwitchPromptState, - rate_limit_poller: Option>, - // Stream lifecycle controller - stream_controller: Option, - running_commands: HashMap, - suppressed_exec_calls: HashSet, - last_unified_wait: Option, - task_complete_pending: bool, - /// Tracks whether codex-core currently considers an agent turn to be in progress. - /// - /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) - /// can update the status header without accidentally clearing the spinner for an active turn. - agent_turn_running: bool, - /// Tracks per-server MCP startup state while startup is in progress. - /// - /// The map is `Some(_)` from the first `McpStartupUpdate` until `McpStartupComplete`, and the - /// bottom pane is treated as "running" while this is populated, even if no agent turn is - /// currently executing. - mcp_startup_status: Option>, - connectors_cache: ConnectorsCacheState, - // Queue of interruptive UI events deferred during an active write cycle - interrupts: InterruptManager, - // Accumulates the current reasoning block text to extract a header - reasoning_buffer: String, - // Accumulates full reasoning content for transcript-only recording - full_reasoning_buffer: String, - // Current status header shown in the status indicator. - current_status_header: String, - // Previous status header to restore after a transient stream retry. - retry_status_header: Option, - conversation_id: Option, - forked_from: Option, - frame_requester: FrameRequester, - // Whether to include the initial welcome banner on session configured - show_welcome_banner: bool, - // When resuming an existing session (selected via resume picker), avoid an - // immediate redraw on SessionConfigured to prevent a gratuitous UI flicker. - suppress_session_configured_redraw: bool, - // User messages queued while a turn is in progress - queued_user_messages: VecDeque, - // Pending notification to show when unfocused on next Draw - pending_notification: Option, - /// When `Some`, the user has pressed a quit shortcut and the second press - /// must occur before `quit_shortcut_expires_at`. - quit_shortcut_expires_at: Option, - /// Tracks which quit shortcut key was pressed first. - /// - /// We require the second press to match this key so `Ctrl+C` followed by - /// `Ctrl+D` (or vice versa) doesn't quit accidentally. - quit_shortcut_key: Option, - // Simple review mode flag; used to adjust layout and banners. - is_review_mode: bool, - // Snapshot of token usage to restore after review mode exits. - pre_review_token_info: Option>, - // Whether the next streamed assistant content should be preceded by a final message separator. - // - // This is set whenever we insert a visible history cell that conceptually belongs to a turn. - // The separator itself is only rendered if the turn recorded "work" activity (see - // `had_work_activity`). - needs_final_message_separator: bool, - // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). - // - // This gates rendering of the "Worked for …" separator so purely conversational turns don't - // show an empty divider. It is reset when the separator is emitted. - had_work_activity: bool, - - last_rendered_width: std::cell::Cell>, - // Feedback sink for /feedback - feedback: codex_feedback::CodexFeedback, - // Current session rollout path (if known) - current_rollout_path: Option, -} - -/// Snapshot of active-cell state that affects transcript overlay rendering. -/// -/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets -/// it cheaply decide when to recompute that tail as the active cell evolves. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct ActiveCellTranscriptKey { - /// Cache-busting revision for in-place updates. - /// - /// Many active cells are updated incrementally while streaming (for example when exec groups - /// add output or change status), and the transcript overlay caches its live tail, so this - /// revision gives a cheap way to say "same active cell, but its transcript output is different - /// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`. - pub(crate) revision: u64, - /// Whether the active cell continues the prior stream, which affects - /// spacing between transcript blocks. - pub(crate) is_stream_continuation: bool, - /// Optional animation tick for time-dependent transcript output. - /// - /// When this changes, the overlay recomputes the cached tail even if the revision and width - /// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any - /// underlying data change. - pub(crate) animation_tick: Option, -} - -struct UserMessage { - text: String, - image_paths: Vec, -} - -impl From for UserMessage { - fn from(text: String) -> Self { - Self { - text, - image_paths: Vec::new(), - } - } -} - -impl From<&str> for UserMessage { - fn from(text: &str) -> Self { - Self { - text: text.to_string(), - image_paths: Vec::new(), - } - } -} - -fn create_initial_user_message(text: String, image_paths: Vec) -> Option { - if text.is_empty() && image_paths.is_empty() { - None - } else { - Some(UserMessage { text, image_paths }) - } -} - -impl ChatWidget { - /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. - /// - /// The bottom pane only has one running flag, but this module treats it as a derived state of - /// both the agent turn lifecycle and MCP startup lifecycle. - fn update_task_running_state(&mut self) { - self.bottom_pane - .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); - } - fn flush_answer_stream_with_separator(&mut self) { - if let Some(mut controller) = self.stream_controller.take() - && let Some(cell) = controller.finalize() - { - self.add_boxed_history(cell); - } - } - - /// Update the status indicator header and details. - /// - /// Passing `None` clears any existing details. - fn set_status(&mut self, header: String, details: Option) { - self.current_status_header = header.clone(); - self.bottom_pane.update_status(header, details); - } - - /// Convenience wrapper around [`Self::set_status`]; - /// updates the status indicator header and clears any existing details. - fn set_status_header(&mut self, header: String) { - self.set_status(header, None); - } - - fn restore_retry_status_header_if_present(&mut self) { - if let Some(header) = self.retry_status_header.take() { - self.set_status_header(header); - } - } - - // --- Small event handlers --- - fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { - self.bottom_pane - .set_history_metadata(event.history_log_id, event.history_entry_count); - self.set_skills(None); - self.conversation_id = Some(event.session_id); - self.forked_from = event.forked_from_id; - self.current_rollout_path = Some(event.rollout_path.clone()); - let initial_messages = event.initial_messages.clone(); - let model_for_header = event.model.clone(); - self.model = Some(model_for_header.clone()); - self.session_header.set_model(&model_for_header); - let session_info_cell = history_cell::new_session_info( - &self.config, - &model_for_header, - event, - self.show_welcome_banner, - ); - self.apply_session_info_cell(session_info_cell); - - if let Some(messages) = initial_messages { - self.replay_initial_messages(messages); - } - // Ask codex-core to enumerate custom prompts for this session. - self.submit_op(Op::ListCustomPrompts); - self.submit_op(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }); - if self.connectors_enabled() { - self.prefetch_connectors(); - } - if let Some(user_message) = self.initial_user_message.take() { - self.submit_user_message(user_message); - } - if !self.suppress_session_configured_redraw { - self.request_redraw(); - } - } - - fn set_skills(&mut self, skills: Option>) { - self.bottom_pane.set_skills(skills); - } - - fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { - let skills = skills_for_cwd(&self.config.cwd, &response.skills); - self.set_skills(Some(skills)); - } - - pub(crate) fn open_feedback_note( - &mut self, - category: crate::app_event::FeedbackCategory, - include_logs: bool, - ) { - // Build a fresh snapshot at the time of opening the note overlay. - let snapshot = self.feedback.snapshot(self.conversation_id); - let rollout = if include_logs { - self.current_rollout_path.clone() - } else { - None - }; - let view = crate::bottom_pane::FeedbackNoteView::new( - category, - snapshot, - rollout, - self.app_event_tx.clone(), - include_logs, - ); - self.bottom_pane.show_view(Box::new(view)); - self.request_redraw(); - } - - pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { - let params = crate::bottom_pane::feedback_upload_consent_params( - self.app_event_tx.clone(), - category, - self.current_rollout_path.clone(), - ); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - } - - fn on_agent_message(&mut self, message: String) { - // If we have a stream_controller, then the final agent message is redundant and will be a - // duplicate of what has already been streamed. - if self.stream_controller.is_none() { - self.handle_streaming_delta(message); - } - self.flush_answer_stream_with_separator(); - self.handle_stream_finished(); - self.request_redraw(); - } - - fn on_agent_message_delta(&mut self, delta: String) { - self.handle_streaming_delta(delta); - } - - fn on_agent_reasoning_delta(&mut self, delta: String) { - // For reasoning deltas, do not stream to history. Accumulate the - // current reasoning block and extract the first bold element - // (between **/**) as the chunk header. Show this header as status. - self.reasoning_buffer.push_str(&delta); - - if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. - } - self.request_redraw(); - } - - fn on_agent_reasoning_final(&mut self) { - // At the end of a reasoning block, record transcript-only content. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - if !self.full_reasoning_buffer.is_empty() { - let cell = - history_cell::new_reasoning_summary_block(self.full_reasoning_buffer.clone()); - self.add_boxed_history(cell); - } - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.request_redraw(); - } - - fn on_reasoning_section_break(&mut self) { - // Start a new reasoning block for header extraction and accumulate transcript. - self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - self.full_reasoning_buffer.push_str("\n\n"); - self.reasoning_buffer.clear(); - } - - // Raw reasoning uses the same flow as summarized reasoning - - fn on_task_started(&mut self) { - self.agent_turn_running = true; - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.update_task_running_state(); - self.retry_status_header = None; - self.bottom_pane.set_interrupt_hint_visible(true); - self.set_status_header(String::from("Working")); - self.full_reasoning_buffer.clear(); - self.reasoning_buffer.clear(); - self.request_redraw(); - } - - fn on_task_complete(&mut self, last_agent_message: Option) { - // If a stream is currently active, finalize it. - self.flush_answer_stream_with_separator(); - // Mark task stopped and request redraw now that all content is in history. - self.agent_turn_running = false; - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.request_redraw(); - - // If there is a queued user message, send exactly one now to begin the next turn. - self.maybe_send_next_queued_input(); - // Emit a notification when the turn completes (suppressed if focused). - self.notify(Notification::AgentTurnComplete { - response: last_agent_message.unwrap_or_default(), - }); - - self.maybe_show_pending_rate_limit_prompt(); - } - - pub(crate) fn set_token_info(&mut self, info: Option) { - match info { - Some(info) => self.apply_token_info(info), - None => { - self.bottom_pane.set_context_window(None, None); - self.token_info = None; - } - } - } - - fn apply_token_info(&mut self, info: TokenUsageInfo) { - let percent = self.context_remaining_percent(&info); - let used_tokens = self.context_used_tokens(&info, percent.is_some()); - self.bottom_pane.set_context_window(percent, used_tokens); - self.token_info = Some(info); - } - - fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { - info.model_context_window.map(|window| { - info.last_token_usage - .percent_of_context_window_remaining(window) - }) - } - - fn context_used_tokens(&self, info: &TokenUsageInfo, percent_known: bool) -> Option { - if percent_known { - return None; - } - - Some(info.total_token_usage.tokens_in_context_window()) - } - - fn restore_pre_review_token_info(&mut self) { - if let Some(saved) = self.pre_review_token_info.take() { - match saved { - Some(info) => self.apply_token_info(info), - None => { - self.bottom_pane.set_context_window(None, None); - self.token_info = None; - } - } - } - } - - pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { - if let Some(mut snapshot) = snapshot { - if snapshot.credits.is_none() { - snapshot.credits = self - .rate_limit_snapshot - .as_ref() - .and_then(|display| display.credits.as_ref()) - .map(|credits| CreditsSnapshot { - has_credits: credits.has_credits, - unlimited: credits.unlimited, - balance: credits.balance.clone(), - }); - } - - self.plan_type = snapshot.plan_type.or(self.plan_type); - - let warnings = self.rate_limit_warnings.take_warnings( - snapshot - .secondary - .as_ref() - .map(|window| window.used_percent), - snapshot - .secondary - .as_ref() - .and_then(|window| window.window_minutes), - snapshot.primary.as_ref().map(|window| window.used_percent), - snapshot - .primary - .as_ref() - .and_then(|window| window.window_minutes), - ); - - let high_usage = snapshot - .secondary - .as_ref() - .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false) - || snapshot - .primary - .as_ref() - .map(|w| w.used_percent >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false); - - if high_usage - && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != Some(NUDGE_MODEL_SLUG) - && !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - ) - { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; - } - - let display = crate::status::rate_limit_snapshot_display(&snapshot, Local::now()); - self.rate_limit_snapshot = Some(display); - - if !warnings.is_empty() { - for warning in warnings { - self.add_to_history(history_cell::new_warning_event(warning)); - } - self.request_redraw(); - } - } else { - self.rate_limit_snapshot = None; - } - } - /// Finalize any active exec as failed and stop/clear agent-turn UI state. - /// - /// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup - /// and should continue to drive the bottom-pane running indicator while it is in progress. - fn finalize_turn(&mut self) { - // Ensure any spinner is replaced by a red ✗ and flushed into history. - self.finalize_active_cell_as_failed(); - // Reset running state and clear streaming buffers. - self.agent_turn_running = false; - self.update_task_running_state(); - self.running_commands.clear(); - self.suppressed_exec_calls.clear(); - self.last_unified_wait = None; - self.stream_controller = None; - self.maybe_show_pending_rate_limit_prompt(); - } - - fn on_error(&mut self, message: String) { - self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - - // After an error ends the turn, try sending the next queued input. - self.maybe_send_next_queued_input(); - } - - fn on_warning(&mut self, message: impl Into) { - self.add_to_history(history_cell::new_warning_event(message.into())); - self.request_redraw(); - } - - fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { - let mut status = self.mcp_startup_status.take().unwrap_or_default(); - if let McpStartupStatus::Failed { error } = &ev.status { - self.on_warning(error); - } - status.insert(ev.server, ev.status); - self.mcp_startup_status = Some(status); - self.update_task_running_state(); - if let Some(current) = &self.mcp_startup_status { - let total = current.len(); - let mut starting: Vec<_> = current - .iter() - .filter_map(|(name, state)| { - if matches!(state, McpStartupStatus::Starting) { - Some(name) - } else { - None - } - }) - .collect(); - starting.sort(); - if let Some(first) = starting.first() { - let completed = total.saturating_sub(starting.len()); - let max_to_show = 3; - let mut to_show: Vec = starting - .iter() - .take(max_to_show) - .map(ToString::to_string) - .collect(); - if starting.len() > max_to_show { - to_show.push("…".to_string()); - } - let header = if total > 1 { - format!( - "Starting MCP servers ({completed}/{total}): {}", - to_show.join(", ") - ) - } else { - format!("Booting MCP server: {first}") - }; - self.set_status_header(header); - } - } - self.request_redraw(); - } - - fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) { - let mut parts = Vec::new(); - if !ev.failed.is_empty() { - let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect(); - parts.push(format!("failed: {}", failed_servers.join(", "))); - } - if !ev.cancelled.is_empty() { - self.on_warning(format!( - "MCP startup interrupted. The following servers were not initialized: {}", - ev.cancelled.join(", ") - )); - } - if !parts.is_empty() { - self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); - } - - self.mcp_startup_status = None; - self.update_task_running_state(); - self.maybe_send_next_queued_input(); - self.request_redraw(); - } - - /// Handle a turn aborted due to user interrupt (Esc). - /// When there are queued user messages, restore them into the composer - /// separated by newlines rather than auto‑submitting the next one. - fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { - // Finalize, log a gentle prompt, and clear running state. - self.finalize_turn(); - - if reason != TurnAbortReason::ReviewEnded { - self.add_to_history(history_cell::new_error_event( - "Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(), - )); - } - - // If any messages were queued during the task, restore them into the composer. - if !self.queued_user_messages.is_empty() { - let queued_text = self - .queued_user_messages - .iter() - .map(|m| m.text.clone()) - .collect::>() - .join("\n"); - let existing_text = self.bottom_pane.composer_text(); - let combined = if existing_text.is_empty() { - queued_text - } else if queued_text.is_empty() { - existing_text - } else { - format!("{queued_text}\n{existing_text}") - }; - self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. - self.queued_user_messages.clear(); - self.refresh_queued_user_messages(); - } - - self.request_redraw(); - } - - fn on_plan_update(&mut self, update: UpdatePlanArgs) { - self.add_to_history(history_cell::new_plan_update(update)); - } - - fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { - let id2 = id.clone(); - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_exec_approval(id, ev), - |s| s.handle_exec_approval_now(id2, ev2), - ); - } - - fn on_apply_patch_approval_request(&mut self, id: String, ev: ApplyPatchApprovalRequestEvent) { - let id2 = id.clone(); - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_apply_patch_approval(id, ev), - |s| s.handle_apply_patch_approval_now(id2, ev2), - ); - } - - fn on_elicitation_request(&mut self, ev: ElicitationRequestEvent) { - let ev2 = ev.clone(); - self.defer_or_handle( - |q| q.push_elicitation(ev), - |s| s.handle_elicitation_request_now(ev2), - ); - } - - fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { - self.flush_answer_stream_with_separator(); - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2)); - } - - fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { - let Some(cell) = self - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - else { - return; - }; - - if cell.append_output(&ev.call_id, std::str::from_utf8(&ev.chunk).unwrap_or("")) { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - - fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { - // TODO: Handle once design is ready - } - - fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { - self.add_to_history(history_cell::new_patch_event( - event.changes, - &self.config.cwd, - )); - } - - fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_view_image_tool_call( - event.path, - &self.config.cwd, - )); - self.request_redraw(); - } - - fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) { - let ev2 = event.clone(); - self.defer_or_handle( - |q| q.push_patch_end(event), - |s| s.handle_patch_apply_end_now(ev2), - ); - } - - fn on_exec_command_end(&mut self, ev: ExecCommandEndEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_exec_end(ev), |s| s.handle_exec_end_now(ev2)); - } - - fn on_mcp_tool_call_begin(&mut self, ev: McpToolCallBeginEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_begin(ev), |s| s.handle_mcp_begin_now(ev2)); - } - - fn on_mcp_tool_call_end(&mut self, ev: McpToolCallEndEvent) { - let ev2 = ev.clone(); - self.defer_or_handle(|q| q.push_mcp_end(ev), |s| s.handle_mcp_end_now(ev2)); - } - - fn on_web_search_begin(&mut self, _ev: WebSearchBeginEvent) { - self.flush_answer_stream_with_separator(); - } - - fn on_web_search_end(&mut self, ev: WebSearchEndEvent) { - self.flush_answer_stream_with_separator(); - self.add_to_history(history_cell::new_web_search_call(ev.query)); - } - - fn on_collab_event(&mut self, cell: PlainHistoryCell) { - self.flush_answer_stream_with_separator(); - self.add_to_history(cell); - self.request_redraw(); - } - - fn on_get_history_entry_response( - &mut self, - event: codex_core::protocol::GetHistoryEntryResponseEvent, - ) { - let codex_core::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry, - } = event; - self.bottom_pane - .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); - } - - fn on_shutdown_complete(&mut self) { - self.request_immediate_exit(); - } - - fn on_turn_diff(&mut self, unified_diff: String) { - debug!("TurnDiffEvent: {unified_diff}"); - } - - fn on_deprecation_notice(&mut self, event: DeprecationNoticeEvent) { - let DeprecationNoticeEvent { summary, details } = event; - self.add_to_history(history_cell::new_deprecation_notice(summary, details)); - self.request_redraw(); - } - - fn on_background_event(&mut self, message: String) { - debug!("BackgroundEvent: {message}"); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(true); - self.set_status_header(message); - } - - fn on_undo_started(&mut self, event: UndoStartedEvent) { - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(false); - let message = event - .message - .unwrap_or_else(|| "Undo in progress...".to_string()); - self.set_status_header(message); - } - - fn on_undo_completed(&mut self, event: UndoCompletedEvent) { - let UndoCompletedEvent { success, message } = event; - self.bottom_pane.hide_status_indicator(); - let message = message.unwrap_or_else(|| { - if success { - "Undo completed successfully.".to_string() - } else { - "Undo failed.".to_string() - } - }); - if success { - self.add_info_message(message, None); - } else { - self.add_error_message(message); - } - } - - fn on_stream_error(&mut self, message: String, additional_details: Option) { - if self.retry_status_header.is_none() { - self.retry_status_header = Some(self.current_status_header.clone()); - } - self.set_status(message, additional_details); - } - - /// Periodic tick to commit at most one queued line to history with a small delay, - /// animating the output. - pub(crate) fn on_commit_tick(&mut self) { - if let Some(controller) = self.stream_controller.as_mut() { - let (cell, is_idle) = controller.on_commit_tick(); - if let Some(cell) = cell { - self.bottom_pane.hide_status_indicator(); - self.add_boxed_history(cell); - self.request_redraw(); - } - if is_idle { - self.app_event_tx.send(AppEvent::StopCommitAnimation); - } - } - } - - fn flush_interrupt_queue(&mut self) { - let mut mgr = std::mem::take(&mut self.interrupts); - mgr.flush_all(self); - self.interrupts = mgr; - } - - #[inline] - fn defer_or_handle( - &mut self, - push: impl FnOnce(&mut InterruptManager), - handle: impl FnOnce(&mut Self), - ) { - // Preserve deterministic FIFO across queued interrupts: once anything - // is queued due to an active write cycle, continue queueing until the - // queue is flushed to avoid reordering (e.g., ExecEnd before ExecBegin). - if self.stream_controller.is_some() || !self.interrupts.is_empty() { - push(&mut self.interrupts); - } else { - handle(self); - } - } - - fn handle_stream_finished(&mut self) { - if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); - self.task_complete_pending = false; - } - // A completed stream indicates non-exec content was just inserted. - self.flush_interrupt_queue(); - } - - #[inline] - fn handle_streaming_delta(&mut self, delta: String) { - // Before streaming agent content, flush any active exec cell group. - let mut needs_redraw = self.active_cell.is_some(); - self.flush_active_cell(); - - if self.stream_controller.is_none() { - // If the previous turn inserted non-stream history (exec output, patch status, MCP - // calls), render a separator before starting the next streamed assistant message. - if self.needs_final_message_separator && self.had_work_activity { - let elapsed_seconds = self - .bottom_pane - .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); - self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); - self.needs_final_message_separator = false; - self.had_work_activity = false; - needs_redraw = true; - } else if self.needs_final_message_separator { - // Reset the flag even if we don't show separator (no work was done) - self.needs_final_message_separator = false; - } - // Streaming must not capture the current viewport width: width-derived wraps are - // applied later, at render time, so the transcript can reflow on resize. - self.stream_controller = Some(StreamController::new()); - } - if let Some(controller) = self.stream_controller.as_mut() - && controller.push(&delta) - { - self.app_event_tx.send(AppEvent::StartCommitAnimation); - } - if needs_redraw { - self.request_redraw(); - } - } - - pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) { - let running = self.running_commands.remove(&ev.call_id); - if self.suppressed_exec_calls.remove(&ev.call_id) { - return; - } - let (command, parsed, source) = match running { - Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), - }; - let is_unified_exec_interaction = - matches!(source, ExecCommandSource::UnifiedExecInteraction); - - let needs_new = self - .active_cell - .as_ref() - .map(|cell| cell.as_any().downcast_ref::().is_none()) - .unwrap_or(true); - if needs_new { - self.flush_active_cell(); - self.active_cell = Some(Box::new(new_active_exec_command( - ev.call_id.clone(), - command, - parsed, - source, - ev.interaction_input.clone(), - self.config.animations, - ))); - } - - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - { - let output = if is_unified_exec_interaction { - CommandOutput { - exit_code: ev.exit_code, - formatted_output: String::new(), - aggregated_output: String::new(), - } - } else { - CommandOutput { - exit_code: ev.exit_code, - formatted_output: ev.formatted_output.clone(), - aggregated_output: ev.aggregated_output.clone(), - } - }; - cell.complete_call(&ev.call_id, output, ev.duration); - if cell.should_flush() { - self.flush_active_cell(); - } else { - self.bump_active_cell_revision(); - self.request_redraw(); - } - } - // Mark that actual work was done (command executed) - self.had_work_activity = true; - } - - pub(crate) fn handle_patch_apply_end_now( - &mut self, - event: codex_core::protocol::PatchApplyEndEvent, - ) { - // 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)); - } - // Mark that actual work was done (patch applied) - self.had_work_activity = true; - } - - pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { - self.flush_answer_stream_with_separator(); - let command = shlex::try_join(ev.command.iter().map(String::as_str)) - .unwrap_or_else(|_| ev.command.join(" ")); - self.notify(Notification::ExecApprovalRequested { command }); - - let request = ApprovalRequest::Exec { - id, - command: ev.command, - reason: ev.reason, - proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.request_redraw(); - } - - pub(crate) fn handle_apply_patch_approval_now( - &mut self, - id: String, - ev: ApplyPatchApprovalRequestEvent, - ) { - self.flush_answer_stream_with_separator(); - - let request = ApprovalRequest::ApplyPatch { - id, - reason: ev.reason, - changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.request_redraw(); - self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.clone(), - changes: ev.changes.keys().cloned().collect(), - }); - } - - pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) { - self.flush_answer_stream_with_separator(); - - self.notify(Notification::ElicitationRequested { - server_name: ev.server_name.clone(), - }); - - let request = ApprovalRequest::McpElicitation { - server_name: ev.server_name, - request_id: ev.id, - message: ev.message, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); - self.request_redraw(); - } - - pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { - // Ensure the status indicator is visible while the command runs. - self.running_commands.insert( - ev.call_id.clone(), - RunningCommand { - command: ev.command.clone(), - parsed_cmd: ev.parsed_cmd.clone(), - source: ev.source, - }, - ); - let is_wait_interaction = matches!(ev.source, ExecCommandSource::UnifiedExecInteraction) - && ev - .interaction_input - .as_deref() - .map(str::is_empty) - .unwrap_or(true); - let command_display = ev.command.join(" "); - let should_suppress_unified_wait = is_wait_interaction - && self - .last_unified_wait - .as_ref() - .is_some_and(|wait| wait.is_duplicate(&command_display)); - if is_wait_interaction { - self.last_unified_wait = Some(UnifiedExecWaitState::new(command_display)); - } else { - self.last_unified_wait = None; - } - if should_suppress_unified_wait { - self.suppressed_exec_calls.insert(ev.call_id); - return; - } - let interaction_input = ev.interaction_input.clone(); - if let Some(cell) = self - .active_cell - .as_mut() - .and_then(|c| c.as_any_mut().downcast_mut::()) - && let Some(new_exec) = cell.with_added_call( - ev.call_id.clone(), - ev.command.clone(), - ev.parsed_cmd.clone(), - ev.source, - interaction_input.clone(), - ) - { - *cell = new_exec; - self.bump_active_cell_revision(); - } else { - self.flush_active_cell(); - - self.active_cell = Some(Box::new(new_active_exec_command( - ev.call_id.clone(), - ev.command.clone(), - ev.parsed_cmd, - ev.source, - interaction_input, - self.config.animations, - ))); - self.bump_active_cell_revision(); - } - - self.request_redraw(); - } - - pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { - self.flush_answer_stream_with_separator(); - self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( - ev.call_id, - ev.invocation, - self.config.animations, - ))); - self.bump_active_cell_revision(); - self.request_redraw(); - } - pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { - self.flush_answer_stream_with_separator(); - - let McpToolCallEndEvent { - call_id, - invocation, - duration, - result, - } = ev; - - let extra_cell = match self - .active_cell - .as_mut() - .and_then(|cell| cell.as_any_mut().downcast_mut::()) - { - Some(cell) if cell.call_id() == call_id => cell.complete(duration, result), - _ => { - self.flush_active_cell(); - let mut cell = history_cell::new_active_mcp_tool_call( - call_id, - invocation, - self.config.animations, - ); - let extra_cell = cell.complete(duration, result); - self.active_cell = Some(Box::new(cell)); - extra_cell - } - }; - - self.flush_active_cell(); - if let Some(extra) = extra_cell { - self.add_boxed_history(extra); - } - // Mark that actual work was done (MCP tool call) - self.had_work_activity = true; - } - - pub(crate) fn new(common: ChatWidgetInit, thread_manager: Arc) -> Self { - let ChatWidgetInit { - config, - frame_requester, - app_event_tx, - initial_prompt, - initial_images, - enhanced_keys_supported, - auth_manager, - models_manager, - feedback, - is_first_run, - model, - } = common; - let mut config = config; - let model = model.filter(|m| !m.trim().is_empty()); - config.model = model.clone(); - let mut rng = rand::rng(); - let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - - let model_for_header = config - .model - .clone() - .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); - let active_cell = if model.is_none() { - Some(Self::placeholder_session_header_cell(&config)) - } else { - None - }; - - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell, - active_cell_revision: 0, - config, - model, - collaboration_mode: CollaborationModeSelection::default(), - auth_manager, - models_manager, - session_header: SessionHeader::new(model_for_header), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - task_complete_pending: false, - agent_turn_running: false, - mcp_startup_status: None, - connectors_cache: ConnectorsCacheState::default(), - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - conversation_id: None, - forked_from: None, - queued_user_messages: VecDeque::new(), - show_welcome_banner: is_first_run, - suppress_session_configured_redraw: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - }; - - widget.prefetch_rate_limits(); - widget - .bottom_pane - .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); - widget - .bottom_pane - .set_connectors_enabled(widget.config.features.enabled(Feature::Connectors)); - - widget - } - - /// Create a ChatWidget attached to an existing conversation (e.g., a fork). - pub(crate) fn new_from_existing( - common: ChatWidgetInit, - conversation: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, - ) -> Self { - let ChatWidgetInit { - mut config, - frame_requester, - app_event_tx, - initial_prompt, - initial_images, - enhanced_keys_supported, - auth_manager, - models_manager, - feedback, - model, - .. - } = common; - let model = model.filter(|m| !m.trim().is_empty()); - config.model = model.clone(); - let mut rng = rand::rng(); - let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - - let header_model = model.unwrap_or_else(|| session_configured.model.clone()); - - let codex_op_tx = - spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); - - let mut widget = Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), - codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, - skills: None, - }), - active_cell: None, - active_cell_revision: 0, - config, - model: Some(header_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager, - models_manager, - session_header: SessionHeader::new(header_model), - initial_user_message: create_initial_user_message( - initial_prompt.unwrap_or_default(), - initial_images, - ), - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - task_complete_pending: false, - agent_turn_running: false, - mcp_startup_status: None, - connectors_cache: ConnectorsCacheState::default(), - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - conversation_id: None, - forked_from: None, - queued_user_messages: VecDeque::new(), - show_welcome_banner: false, - suppress_session_configured_redraw: true, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_rendered_width: std::cell::Cell::new(None), - feedback, - current_rollout_path: None, - }; - - widget.prefetch_rate_limits(); - widget - .bottom_pane - .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); - widget - .bottom_pane - .set_connectors_enabled(widget.config.features.enabled(Feature::Connectors)); - - widget - } - - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { - match key_event { - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { - self.on_ctrl_c(); - return; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => { - if self.on_ctrl_d() { - return; - } - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - } - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press, - .. - } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && c.eq_ignore_ascii_case(&'v') => - { - match paste_image_to_temp_png() { - Ok((path, info)) => { - tracing::debug!( - "pasted image size={}x{} format={}", - info.width, - info.height, - info.encoded_format.label() - ); - self.attach_image(path); - } - Err(err) => { - tracing::warn!("failed to paste image: {err}"); - self.add_to_history(history_cell::new_error_event(format!( - "Failed to paste image: {err}", - ))); - } - } - return; - } - other if other.kind == KeyEventKind::Press => { - self.bottom_pane.clear_quit_shortcut_hint(); - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - } - _ => {} - } - - match key_event { - KeyEvent { - code: KeyCode::BackTab, - kind: KeyEventKind::Press, - .. - } if self.collaboration_modes_enabled() - && !self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() => - { - self.cycle_collaboration_mode(); - } - KeyEvent { - code: KeyCode::Up, - modifiers: KeyModifiers::ALT, - kind: KeyEventKind::Press, - .. - } if !self.queued_user_messages.is_empty() => { - // Prefer the most recently queued item. - if let Some(user_message) = self.queued_user_messages.pop_back() { - self.bottom_pane.set_composer_text(user_message.text); - self.refresh_queued_user_messages(); - self.request_redraw(); - } - } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // Enter always sends messages immediately (bypasses queue check) - // Clear any reasoning status header when submitting a new message - self.reasoning_buffer.clear(); - self.full_reasoning_buffer.clear(); - self.set_status_header(String::from("Working")); - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - if !self.is_session_configured() { - self.queue_user_message(user_message); - } else { - self.submit_user_message(user_message); - } - } - InputResult::Queued(text) => { - // Tab queues the message if a task is running, otherwise submits immediately - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - self.queue_user_message(user_message); - } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} - } - } - } - } - - pub(crate) fn attach_image(&mut self, path: PathBuf) { - tracing::info!("attach_image path={path:?}"); - self.bottom_pane.attach_image(path); - self.request_redraw(); - } - - 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.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - match cmd { - SlashCommand::Feedback => { - if !self.config.feedback_enabled { - let params = crate::bottom_pane::feedback_disabled_params(); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - return; - } - // Step 1: pick a category (UI built in feedback_view) - let params = - crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone()); - self.bottom_pane.show_selection_view(params); - self.request_redraw(); - } - SlashCommand::New => { - self.app_event_tx.send(AppEvent::NewSession); - } - SlashCommand::Resume => { - self.app_event_tx.send(AppEvent::OpenResumePicker); - } - SlashCommand::Fork => { - self.app_event_tx.send(AppEvent::ForkCurrentSession); - } - SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); - if init_target.exists() { - let message = format!( - "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." - ); - self.add_info_message(message, None); - return; - } - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - self.submit_user_message(INIT_PROMPT.to_string().into()); - } - SlashCommand::Compact => { - self.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } - SlashCommand::Review => { - self.open_review_popup(); - } - SlashCommand::Model => { - self.open_model_popup(); - } - SlashCommand::Collab => { - if self.collaboration_modes_enabled() { - self.cycle_collaboration_mode(); - } - } - SlashCommand::Approvals => { - self.open_approvals_popup(); - } - SlashCommand::ElevateSandbox => { - #[cfg(target_os = "windows")] - { - let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() - .is_some() - && !codex_core::is_windows_elevated_sandbox_enabled(); - if !windows_degraded_sandbox_enabled - || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - { - // This command should not be visible/recognized outside degraded mode, - // but guard anyway in case something dispatches it directly. - return; - } - - let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - else { - // Avoid panicking in interactive UI; treat this as a recoverable - // internal error. - self.add_error_message( - "Internal error: missing the 'auto' approval preset.".to_string(), - ); - return; - }; - - if let Err(err) = self.config.approval_policy.can_set(&preset.approval) { - self.add_error_message(err.to_string()); - return; - } - - self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); - } - #[cfg(not(target_os = "windows"))] - { - // Not supported; on non-Windows this command should never be reachable. - }; - } - SlashCommand::Quit | SlashCommand::Exit => { - self.request_quit_without_confirmation(); - } - SlashCommand::Logout => { - if let Err(e) = codex_core::auth::logout( - &self.config.codex_home, - self.config.cli_auth_credentials_store_mode, - ) { - tracing::error!("failed to logout: {e}"); - } - self.request_quit_without_confirmation(); - } - // SlashCommand::Undo => { - // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); - // } - SlashCommand::Diff => { - self.add_diff_in_progress(); - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Mention => { - self.insert_str("@"); - } - SlashCommand::Skills => { - self.insert_str("$"); - } - SlashCommand::Status => { - self.add_status_output(); - } - SlashCommand::Mcp => { - self.add_mcp_output(); - } - SlashCommand::Connectors => { - self.add_connectors_output(); - } - SlashCommand::Rollout => { - if let Some(path) = self.rollout_path() { - self.add_info_message( - format!("Current rollout path: {}", path.display()), - None, - ); - } else { - self.add_info_message("Rollout path is not available yet.".to_string(), None); - } - } - SlashCommand::TestApproval => { - use codex_core::protocol::EventMsg; - use std::collections::HashMap; - - use codex_core::protocol::ApplyPatchApprovalRequestEvent; - use codex_core::protocol::FileChange; - - self.app_event_tx.send(AppEvent::CodexEvent(Event { - id: "1".to_string(), - // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - // call_id: "1".to_string(), - // command: vec!["git".into(), "apply".into()], - // cwd: self.config.cwd.clone(), - // reason: Some("test".to_string()), - // }), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - turn_id: "turn-1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }), - })); - } - } - } - - fn dispatch_command_with_args(&mut self, cmd: SlashCommand, args: String) { - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { - let message = format!( - "'/{}' is disabled while a task is in progress.", - cmd.command() - ); - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - return; - } - - let trimmed = args.trim(); - match cmd { - SlashCommand::Review if !trimmed.is_empty() => { - self.submit_op(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Custom { - instructions: trimmed.to_string(), - }, - user_facing_hint: None, - }, - }); - } - SlashCommand::Collab => { - if !self.collaboration_modes_enabled() { - return; - } - - if let Some(selection) = collaboration_modes::parse_selection(trimmed) { - self.set_collaboration_mode(selection); - } else if !trimmed.is_empty() { - self.add_error_message(format!( - "Unknown collaboration mode '{trimmed}'. Try: plan, pair, execute." - )); - } - } - _ => self.dispatch_command(cmd), - } - } - - pub(crate) fn handle_paste(&mut self, text: String) { - self.bottom_pane.handle_paste(text); - } - - // Returns true if caller should skip rendering this frame (a future frame is scheduled). - pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { - if self.bottom_pane.flush_paste_burst_if_due() { - // A paste just flushed; request an immediate redraw and skip this frame. - self.request_redraw(); - true - } else if self.bottom_pane.is_in_paste_burst() { - // While capturing a burst, schedule a follow-up tick and skip this frame - // to avoid redundant renders between ticks. - frame_requester.schedule_frame_in( - crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), - ); - true - } else { - false - } - } - - fn flush_active_cell(&mut self) { - if let Some(active) = self.active_cell.take() { - self.needs_final_message_separator = true; - self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); - } - } - - fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { - self.add_boxed_history(Box::new(cell)); - } - - fn add_boxed_history(&mut self, cell: Box) { - // Keep the placeholder session header as the active cell until real session info arrives, - // so we can merge headers instead of committing a duplicate box to history. - let keep_placeholder_header_active = !self.is_session_configured() - && self - .active_cell - .as_ref() - .is_some_and(|c| c.as_any().is::()); - - if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() { - // Only break exec grouping if the cell renders visible lines. - self.flush_active_cell(); - self.needs_final_message_separator = true; - } - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); - } - - #[allow(dead_code)] // Used in tests - fn queue_user_message(&mut self, user_message: UserMessage) { - if !self.is_session_configured() || self.bottom_pane.is_task_running() { - self.queued_user_messages.push_back(user_message); - self.refresh_queued_user_messages(); - } else { - self.submit_user_message(user_message); - } - } - - fn submit_user_message(&mut self, user_message: UserMessage) { - let Some(model) = self.current_model().or(self.config.model.as_deref()) else { - tracing::warn!("cannot submit user message before model is known; queueing"); - self.queued_user_messages.push_front(user_message); - self.refresh_queued_user_messages(); - return; - }; - let model = model.to_string(); - - let UserMessage { text, image_paths } = user_message; - if text.is_empty() && image_paths.is_empty() { - return; - } - - let mut items: Vec = Vec::new(); - - // Special-case: "!cmd" executes a local shell command instead of sending to the model. - if let Some(stripped) = text.strip_prefix('!') { - let cmd = stripped.trim(); - if cmd.is_empty() { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - USER_SHELL_COMMAND_HELP_TITLE.to_string(), - Some(USER_SHELL_COMMAND_HELP_HINT.to_string()), - ), - ))); - return; - } - self.submit_op(Op::RunUserShellCommand { - command: cmd.to_string(), - }); - return; - } - - for path in image_paths { - items.push(UserInput::LocalImage { path }); - } - - if !text.is_empty() { - // TODO: Thread text element ranges from the composer input. Empty keeps old behavior. - items.push(UserInput::Text { - text: text.clone(), - text_elements: Vec::new(), - }); - } - - if let Some(skills) = self.bottom_pane.skills() { - let skill_mentions = find_skill_mentions(&text, skills); - for skill in skill_mentions { - items.push(UserInput::Skill { - name: skill.name.clone(), - path: skill.path.clone(), - }); - } - } - - let collaboration_mode = self.collaboration_modes_enabled().then(|| { - collaboration_modes::resolve_mode_or_fallback( - self.models_manager.as_ref(), - self.collaboration_mode, - model.as_str(), - self.config.model_reasoning_effort, - ) - }); - let op = Op::UserTurn { - items, - cwd: self.config.cwd.clone(), - approval_policy: self.config.approval_policy.value(), - sandbox_policy: self.config.sandbox_policy.get().clone(), - model, - effort: self.config.model_reasoning_effort, - summary: self.config.model_reasoning_summary, - final_output_json_schema: None, - collaboration_mode, - }; - - if !self.agent_turn_running { - self.agent_turn_running = true; - self.update_task_running_state(); - } - - self.codex_op_tx.send(op).unwrap_or_else(|e| { - tracing::error!("failed to send message: {e}"); - }); - - // Persist the text to cross-session message history. - if !text.is_empty() { - self.codex_op_tx - .send(Op::AddToHistory { text: text.clone() }) - .unwrap_or_else(|e| { - tracing::error!("failed to send AddHistory op: {e}"); - }); - } - - // Only show the text portion in conversation history. - if !text.is_empty() { - self.add_to_history(history_cell::new_user_prompt(text)); - } - self.needs_final_message_separator = false; - } - - /// Replay a subset of initial events into the UI to seed the transcript when - /// resuming an existing session. This approximates the live event flow and - /// is intentionally conservative: only safe-to-replay items are rendered to - /// avoid triggering side effects. Event ids are passed as `None` to - /// distinguish replayed events from live ones. - fn replay_initial_messages(&mut self, events: Vec) { - for msg in events { - if matches!(msg, EventMsg::SessionConfigured(_)) { - continue; - } - // `id: None` indicates a synthetic/fake id coming from replay. - self.dispatch_event_msg(None, msg, true); - } - } - - pub(crate) fn handle_codex_event(&mut self, event: Event) { - let Event { id, msg } = event; - self.dispatch_event_msg(Some(id), msg, false); - } - - /// Dispatch a protocol `EventMsg` to the appropriate handler. - /// - /// `id` is `Some` for live events and `None` for replayed events from - /// `replay_initial_messages()`. Callers should treat `None` as a "fake" id - /// that must not be used to correlate follow-up actions. - fn dispatch_event_msg(&mut self, id: Option, msg: EventMsg, from_replay: bool) { - let is_stream_error = matches!(&msg, EventMsg::StreamError(_)); - if !is_stream_error { - self.restore_retry_status_header_if_present(); - } - - match msg { - EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) => {} - _ => { - tracing::trace!("handle_codex_event: {:?}", msg); - } - } - - match msg { - EventMsg::SessionConfigured(e) => self.on_session_configured(e), - EventMsg::AgentMessage(AgentMessageEvent { message }) => self.on_agent_message(message), - EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - self.on_agent_message_delta(delta) - } - EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) - | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { - delta, - }) => self.on_agent_reasoning_delta(delta), - EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - self.on_agent_reasoning_delta(text); - self.on_agent_reasoning_final(); - } - EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), - EventMsg::TurnStarted(_) => self.on_task_started(), - EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }) => { - self.on_task_complete(last_agent_message) - } - EventMsg::TokenCount(ev) => { - self.set_token_info(ev.info); - self.on_rate_limit_snapshot(ev.rate_limits); - } - EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), - EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), - EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), - EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), - EventMsg::TurnAborted(ev) => match ev.reason { - TurnAbortReason::Interrupted => { - self.on_interrupted_turn(ev.reason); - } - TurnAbortReason::Replaced => { - self.on_error("Turn aborted: replaced by a new task".to_owned()) - } - TurnAbortReason::ReviewEnded => { - self.on_interrupted_turn(ev.reason); - } - }, - EventMsg::PlanUpdate(update) => self.on_plan_update(update), - EventMsg::ExecApprovalRequest(ev) => { - // For replayed events, synthesize an empty id (these should not occur). - self.on_exec_approval_request(id.unwrap_or_default(), ev) - } - EventMsg::ApplyPatchApprovalRequest(ev) => { - self.on_apply_patch_approval_request(id.unwrap_or_default(), ev) - } - EventMsg::ElicitationRequest(ev) => { - self.on_elicitation_request(ev); - } - EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), - EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), - EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), - EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), - EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), - EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev), - EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev), - EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev), - EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev), - EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev), - EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev), - EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), - EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), - EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), - EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), - EventMsg::SkillsUpdateAvailable => { - self.submit_op(Op::ListSkills { - cwds: Vec::new(), - force_reload: true, - }); - } - EventMsg::ShutdownComplete => self.on_shutdown_complete(), - EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), - EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - self.on_background_event(message) - } - EventMsg::UndoStarted(ev) => self.on_undo_started(ev), - EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), - EventMsg::StreamError(StreamErrorEvent { - message, - additional_details, - .. - }) => self.on_stream_error(message, additional_details), - EventMsg::UserMessage(ev) => { - if from_replay { - self.on_user_message_event(ev); - } - } - EventMsg::EnteredReviewMode(review_request) => { - self.on_entered_review_mode(review_request) - } - EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), - EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), - EventMsg::CollabAgentSpawnBegin(_) => {} - EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)), - EventMsg::CollabAgentInteractionBegin(_) => {} - EventMsg::CollabAgentInteractionEnd(ev) => { - self.on_collab_event(collab::interaction_end(ev)) - } - EventMsg::CollabWaitingBegin(ev) => self.on_collab_event(collab::waiting_begin(ev)), - EventMsg::CollabWaitingEnd(ev) => self.on_collab_event(collab::waiting_end(ev)), - EventMsg::CollabCloseBegin(_) => {} - EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), - EventMsg::RawResponseItem(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::RequestUserInput(_) => {} - } - } - - fn on_entered_review_mode(&mut self, review: ReviewRequest) { - // Enter review mode and emit a concise banner - if self.pre_review_token_info.is_none() { - self.pre_review_token_info = Some(self.token_info.clone()); - } - self.is_review_mode = true; - let hint = review - .user_facing_hint - .unwrap_or_else(|| codex_core::review_prompts::user_facing_hint(&review.target)); - let banner = format!(">> Code review started: {hint} <<"); - self.add_to_history(history_cell::new_review_status_line(banner)); - self.request_redraw(); - } - - fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) { - // Leave review mode; if output is present, flush pending stream + show results. - if let Some(output) = review.review_output { - self.flush_answer_stream_with_separator(); - self.flush_interrupt_queue(); - self.flush_active_cell(); - - if output.findings.is_empty() { - let explanation = output.overall_explanation.trim().to_string(); - if explanation.is_empty() { - tracing::error!("Reviewer failed to output a response."); - self.add_to_history(history_cell::new_error_event( - "Reviewer failed to output a response.".to_owned(), - )); - } else { - // Show explanation when there are no structured findings. - let mut rendered: Vec> = vec!["".into()]; - append_markdown(&explanation, None, &mut rendered); - let body_cell = AgentMessageCell::new(rendered, false); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); - } - } - // Final message is rendered as part of the AgentMessage. - } - - self.is_review_mode = false; - self.restore_pre_review_token_info(); - // Append a finishing banner at the end of this turn. - self.add_to_history(history_cell::new_review_status_line( - "<< Code review finished >>".to_string(), - )); - self.request_redraw(); - } - - fn on_user_message_event(&mut self, event: UserMessageEvent) { - let message = event.message.trim(); - // Only show the text portion in conversation history. - if !message.is_empty() { - self.add_to_history(history_cell::new_user_prompt(message.to_string())); - } - - self.needs_final_message_separator = false; - } - - /// Exit the UI immediately without waiting for shutdown. - /// - /// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits; - /// this is mainly a fallback for shutdown completion or emergency exits. - fn request_immediate_exit(&self) { - self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); - } - - /// Request a shutdown-first quit. - /// - /// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for - /// the double-press Ctrl+C/Ctrl+D quit shortcut. - fn request_quit_without_confirmation(&self) { - self.app_event_tx - .send(AppEvent::Exit(ExitMode::ShutdownFirst)); - } - - fn request_redraw(&mut self) { - self.frame_requester.schedule_frame(); - } - - fn bump_active_cell_revision(&mut self) { - // Wrapping avoids overflow; wraparound would require 2^64 bumps and at - // worst causes a one-time cache-key collision. - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); - } - - fn notify(&mut self, notification: Notification) { - if !notification.allowed_for(&self.config.tui_notifications) { - return; - } - self.pending_notification = Some(notification); - self.request_redraw(); - } - - pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) { - if let Some(notif) = self.pending_notification.take() { - tui.notify(notif.display()); - } - } - - /// Mark the active cell as failed (✗) and flush it into history. - fn finalize_active_cell_as_failed(&mut self) { - if let Some(mut cell) = self.active_cell.take() { - // Insert finalized cell into history and keep grouping consistent. - if let Some(exec) = cell.as_any_mut().downcast_mut::() { - exec.mark_failed(); - } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { - tool.mark_failed(); - } - self.add_boxed_history(cell); - } - } - - // If idle and there are queued inputs, submit exactly one to start the next turn. - fn maybe_send_next_queued_input(&mut self) { - if self.bottom_pane.is_task_running() { - return; - } - if let Some(user_message) = self.queued_user_messages.pop_front() { - self.submit_user_message(user_message); - } - // Update the list to reflect the remaining queued messages (if any). - self.refresh_queued_user_messages(); - } - - /// Rebuild and update the queued user messages from the current queue. - fn refresh_queued_user_messages(&mut self) { - let messages: Vec = self - .queued_user_messages - .iter() - .map(|m| m.text.clone()) - .collect(); - self.bottom_pane.set_queued_user_messages(messages); - } - - pub(crate) fn add_diff_in_progress(&mut self) { - self.request_redraw(); - } - - pub(crate) fn on_diff_complete(&mut self) { - self.request_redraw(); - } - - pub(crate) fn add_status_output(&mut self) { - let default_usage = TokenUsage::default(); - let token_info = self.token_info.as_ref(); - let total_usage = token_info - .map(|ti| &ti.total_token_usage) - .unwrap_or(&default_usage); - self.add_to_history(crate::status::new_status_output( - &self.config, - self.auth_manager.as_ref(), - token_info, - total_usage, - &self.conversation_id, - self.forked_from, - self.rate_limit_snapshot.as_ref(), - self.plan_type, - Local::now(), - self.model_display_name(), - self.collaboration_modes_enabled() - .then_some(self.collaboration_mode.label()), - )); - } - fn stop_rate_limit_poller(&mut self) { - if let Some(handle) = self.rate_limit_poller.take() { - handle.abort(); - } - } - - fn prefetch_connectors(&mut self) { - if !self.connectors_enabled() { - return; - } - if matches!(self.connectors_cache, ConnectorsCacheState::Loading) { - return; - } - - self.connectors_cache = ConnectorsCacheState::Loading; - let config = self.config.clone(); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let result: Result = async { - let connectors = connectors::list_connectors(&config).await?; - Ok(ConnectorsSnapshot { connectors }) - } - .await; - let result = result.map_err(|err| format!("Failed to load apps: {err}")); - app_event_tx.send(AppEvent::ConnectorsLoaded(result)); - }); - } - - fn prefetch_rate_limits(&mut self) { - self.stop_rate_limit_poller(); - - if self.auth_manager.auth_cached().map(|auth| auth.mode) != Some(AuthMode::ChatGPT) { - return; - } - - let base_url = self.config.chatgpt_base_url.clone(); - let app_event_tx = self.app_event_tx.clone(); - let auth_manager = Arc::clone(&self.auth_manager); - - let handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); - - loop { - if let Some(auth) = auth_manager.auth().await - && auth.mode == AuthMode::ChatGPT - && let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth).await - { - app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot)); - } - interval.tick().await; - } - }); - - self.rate_limit_poller = Some(handle); - } - - fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.try_list_models(&self.config).ok()?; - models - .iter() - .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) - .cloned() - } - - fn rate_limit_switch_prompt_hidden(&self) -> bool { - self.config - .notices - .hide_rate_limit_model_nudge - .unwrap_or(false) - } - - fn maybe_show_pending_rate_limit_prompt(&mut self) { - if self.rate_limit_switch_prompt_hidden() { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - return; - } - if !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - ) { - return; - } - if let Some(preset) = self.lower_cost_preset() { - self.open_rate_limit_switch_prompt(preset); - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; - } else { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { - let switch_model = preset.model.to_string(); - let display_name = preset.display_name.to_string(); - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - - let switch_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(switch_model.clone()), - effort: Some(Some(default_effort)), - summary: None, - collaboration_mode: None, - })); - tx.send(AppEvent::UpdateModel(switch_model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); - })]; - - let keep_actions: Vec = Vec::new(); - let never_actions: Vec = vec![Box::new(|tx| { - tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); - tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); - })]; - let description = if preset.description.is_empty() { - Some("Uses fewer credits for upcoming turns.".to_string()) - } else { - Some(preset.description) - }; - - let items = vec![ - SelectionItem { - name: format!("Switch to {display_name}"), - description, - selected_description: None, - is_current: false, - actions: switch_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model".to_string(), - description: None, - selected_description: None, - is_current: false, - actions: keep_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model (never show again)".to_string(), - description: Some( - "Hide future rate limit reminders about switching models.".to_string(), - ), - selected_description: None, - is_current: false, - actions: never_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Approaching rate limits".to_string()), - subtitle: Some(format!("Switch to {display_name} for lower credit usage?")), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - /// Open a popup to choose a quick auto model. Selecting "All models" - /// opens the full picker with every available preset. - pub(crate) fn open_model_popup(&mut self) { - if !self.is_session_configured() { - self.add_info_message( - "Model selection is disabled until startup completes.".to_string(), - None, - ); - return; - } - - let presets: Vec = match self.models_manager.try_list_models(&self.config) { - Ok(models) => models, - Err(_) => { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - None, - ); - return; - } - }; - self.open_model_popup_with_presets(presets); - } - - pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { - let presets: Vec = presets - .into_iter() - .filter(|preset| preset.show_in_picker) - .collect(); - - let current_model = self.current_model(); - let current_label = presets - .iter() - .find(|preset| Some(preset.model.as_str()) == current_model) - .map(|preset| preset.display_name.to_string()) - .unwrap_or_else(|| self.model_display_name().to_string()); - - let (mut auto_presets, other_presets): (Vec, Vec) = presets - .into_iter() - .partition(|preset| Self::is_auto_model(&preset.model)); - - if auto_presets.is_empty() { - self.open_all_models_popup(other_presets); - return; - } - - auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); - - let mut items: Vec = auto_presets - .into_iter() - .map(|preset| { - let description = - (!preset.description.is_empty()).then_some(preset.description.clone()); - let model = preset.model.clone(); - let actions = Self::model_selection_actions( - model.clone(), - Some(preset.default_reasoning_effort), - ); - SelectionItem { - name: preset.display_name.clone(), - description, - is_current: Some(model.as_str()) == current_model, - is_default: preset.is_default, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - if !other_presets.is_empty() { - let all_models = other_presets; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAllModelsPopup { - models: all_models.clone(), - }); - })]; - - let is_current = !items.iter().any(|item| item.is_current); - let description = Some(format!( - "Choose a specific model and reasoning level (current: {current_label})" - )); - - items.push(SelectionItem { - name: "All models".to_string(), - description, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Model".to_string()), - subtitle: Some("Pick a quick auto mode or browse all models.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn is_auto_model(model: &str) -> bool { - model.starts_with("codex-auto-") - } - - fn auto_model_order(model: &str) -> usize { - match model { - "codex-auto-fast" => 0, - "codex-auto-balanced" => 1, - "codex-auto-thorough" => 2, - _ => 3, - } - } - - pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { - if presets.is_empty() { - self.add_info_message( - "No additional models are available right now.".to_string(), - None, - ); - return; - } - - let mut items: Vec = Vec::new(); - for preset in presets.into_iter() { - let description = - (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = Some(preset.model.as_str()) == self.current_model(); - let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; - let preset_for_action = preset.clone(); - let actions: Vec = vec![Box::new(move |tx| { - let preset_for_event = preset_for_action.clone(); - tx.send(AppEvent::OpenReasoningPopup { - model: preset_for_event, - }); - })]; - items.push(SelectionItem { - name: preset.display_name.clone(), - description, - is_current, - is_default: preset.is_default, - actions, - dismiss_on_select: single_supported_effort, - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Model and Effort".to_string()), - subtitle: Some( - "Access legacy models by running codex -m or in your config.toml" - .to_string(), - ), - footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()), - items, - ..Default::default() - }); - } - - fn model_selection_actions( - model_for_action: String, - effort_for_action: Option, - ) -> Vec { - vec![Box::new(move |tx| { - let effort_label = effort_for_action - .map(|effort| effort.to_string()) - .unwrap_or_else(|| "default".to_string()); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(model_for_action.clone()), - effort: Some(effort_for_action), - summary: None, - collaboration_mode: None, - })); - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model_for_action, - effort_label - ); - })] - } - - /// Open a popup to choose the reasoning effort (stage 2) for the given model. - pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - let supported = preset.supported_reasoning_efforts; - - let warn_effort = if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::XHigh) - { - Some(ReasoningEffortConfig::XHigh) - } else if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::High) - { - Some(ReasoningEffortConfig::High) - } else { - None - }; - let warning_text = warn_effort.map(|effort| { - let effort_label = Self::reasoning_effort_label(effort); - format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") - }); - let warn_for_model = preset.model.starts_with("gpt-5.1-codex") - || preset.model.starts_with("gpt-5.1-codex-max") - || preset.model.starts_with("gpt-5.2"); - - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } - if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); - } - - if choices.len() == 1 { - if let Some(effort) = choices.first().and_then(|c| c.stored) { - self.apply_model_and_effort(preset.model, Some(effort)); - } else { - self.apply_model_and_effort(preset.model, None); - } - return; - } - - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) - .or(Some(default_effort)); - - let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == Some(preset.model.as_str()); - let highlight_choice = if is_current_model { - self.config.model_reasoning_effort - } else { - default_choice - }; - let selection_choice = highlight_choice.or(default_choice); - let initial_selected_idx = choices - .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); - let mut items: Vec = Vec::new(); - for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { - effort_label.push_str(" (default)"); - } - - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) - .filter(|text| !text.is_empty()); - - let show_warning = warn_for_model && warn_effort == Some(effort); - let selected_description = if show_warning { - warning_text.as_ref().map(|warning_message| { - description.as_ref().map_or_else( - || warning_message.clone(), - |d| format!("{d}\n{warning_message}"), - ) - }) - } else { - None - }; - - let model_for_action = model_slug.clone(); - let actions = Self::model_selection_actions(model_for_action, choice.stored); - - items.push(SelectionItem { - name: effort_label, - description, - selected_description, - is_current: is_current_model && choice.stored == highlight_choice, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let mut header = ColumnRenderable::new(); - header.push(Line::from( - format!("Select Reasoning Level for {model_slug}").bold(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx, - ..Default::default() - }); - } - - fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { - match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", - } - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.app_event_tx - .send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - sandbox_policy: None, - model: Some(model.clone()), - effort: Some(effort), - summary: None, - collaboration_mode: None, - })); - self.app_event_tx.send(AppEvent::UpdateModel(model.clone())); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - self.app_event_tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); - tracing::info!( - "Selected model: {}, Selected effort: {}", - model, - effort - .map(|e| e.to_string()) - .unwrap_or_else(|| "default".to_string()) - ); - } - - /// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy). - pub(crate) fn open_approvals_popup(&mut self) { - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let mut items: Vec = Vec::new(); - let presets: Vec = builtin_approval_presets(); - - #[cfg(target_os = "windows")] - let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled(); - #[cfg(not(target_os = "windows"))] - let windows_degraded_sandbox_enabled = false; - - let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && windows_degraded_sandbox_enabled - && presets.iter().any(|preset| preset.id == "auto"); - - for preset in presets.into_iter() { - let is_current = - Self::preset_matches_current(current_approval, current_sandbox, &preset); - let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Agent (non-elevated sandbox)".to_string() - } else { - preset.label.to_string() - }; - let description_text = preset.description; - let description = Some(description_text.to_string()); - let requires_confirmation = preset.id == "full-access" - && !self - .config - .notices - .hide_full_access_warning - .unwrap_or(false); - let actions: Vec = if requires_confirmation { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenFullAccessConfirmation { - preset: preset_clone.clone(), - }); - })] - } else if preset.id == "auto" { - #[cfg(target_os = "windows")] - { - if codex_core::get_platform_sandbox().is_none() { - let preset_clone = preset.clone(); - if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && codex_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) - { - vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); - })] - } else { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] - } - } else if let Some((sample_paths, extra_count, failed_scan)) = - self.world_writable_warning_details() - { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset_clone.clone()), - sample_paths: sample_paths.clone(), - extra_count, - failed_scan, - }); - })] - } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - } - } - #[cfg(not(target_os = "windows"))] - { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - } - } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }; - items.push(SelectionItem { - name, - description, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let footer_note = show_elevate_sandbox_hint.then(|| { - vec![ - "The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(), - "/setup-elevated-sandbox".cyan(), - ".".dim(), - ] - .into() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_note, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(()), - ..Default::default() - }); - } - - fn approval_preset_actions( - approval: AskForApproval, - sandbox: SandboxPolicy, - ) -> Vec { - vec![Box::new(move |tx| { - let sandbox_clone = sandbox.clone(); - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - sandbox_policy: Some(sandbox_clone.clone()), - model: None, - effort: None, - summary: None, - collaboration_mode: None, - })); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); - })] - } - - fn preset_matches_current( - current_approval: AskForApproval, - current_sandbox: &SandboxPolicy, - preset: &ApprovalPreset, - ) -> bool { - if current_approval != preset.approval { - return false; - } - matches!( - (&preset.sandbox, current_sandbox), - (SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly) - | ( - SandboxPolicy::DangerFullAccess, - SandboxPolicy::DangerFullAccess - ) - | ( - SandboxPolicy::WorkspaceWrite { .. }, - SandboxPolicy::WorkspaceWrite { .. } - ) - ) - } - - #[cfg(target_os = "windows")] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - if self - .config - .notices - .hide_world_writable_warning - .unwrap_or(false) - { - return None; - } - let cwd = self.config.cwd.clone(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - match codex_windows_sandbox::apply_world_writable_scan_and_denies( - self.config.codex_home.as_path(), - cwd.as_path(), - &env_map, - self.config.sandbox_policy.get(), - Some(self.config.codex_home.as_path()), - ) { - Ok(_) => None, - Err(_) => Some((Vec::new(), 0, true)), - } - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - None - } - - pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { - let approval = preset.approval; - let sandbox = preset.sandbox; - let mut header_children: Vec> = Vec::new(); - let title_line = Line::from("Enable full access?").bold(); - let info_line = Line::from(vec![ - "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " - .into(), - "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." - .fg(Color::Red), - ]); - header_children.push(Box::new(title_line)); - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - let header = ColumnRenderable::with(header_children); - - let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - })); - - let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - tx.send(AppEvent::PersistFullAccessWarningAcknowledged); - })); - - let deny_actions: Vec = vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })]; - - let items = vec![ - SelectionItem { - name: "Yes, continue anyway".to_string(), - description: Some("Apply full access for this session".to_string()), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Yes, and don't ask again".to_string(), - description: Some("Enable full access and remember this choice".to_string()), - actions: accept_and_remember_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Cancel".to_string(), - description: Some("Go back without enabling full access".to_string()), - actions: deny_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - preset: Option, - sample_paths: Vec, - extra_count: usize, - failed_scan: bool, - ) { - let (approval, sandbox) = match &preset { - Some(p) => (Some(p.approval), Some(p.sandbox.clone())), - None => (None, None), - }; - let mut header_children: Vec> = Vec::new(); - let describe_policy = |policy: &SandboxPolicy| match policy { - SandboxPolicy::WorkspaceWrite { .. } => "Agent mode", - SandboxPolicy::ReadOnly => "Read-Only mode", - _ => "Agent mode", - }; - let mode_label = preset - .as_ref() - .map(|p| describe_policy(&p.sandbox)) - .unwrap_or_else(|| describe_policy(self.config.sandbox_policy.get())); - let info_line = if failed_scan { - Line::from(vec![ - "We couldn't complete the world-writable scan, so protections cannot be verified. " - .into(), - format!("The Windows sandbox cannot guarantee protection in {mode_label}.") - .fg(Color::Red), - ]) - } else { - Line::from(vec![ - "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), - " Consider removing write access for Everyone from the following folders:".into(), - ]) - }; - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - - if !sample_paths.is_empty() { - // Show up to three examples and optionally an "and X more" line. - let mut lines: Vec = Vec::new(); - lines.push(Line::from("")); - for p in &sample_paths { - lines.push(Line::from(format!(" - {p}"))); - } - if extra_count > 0 { - lines.push(Line::from(format!("and {extra_count} more"))); - } - header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - } - let header = ColumnRenderable::with(header_children); - - // Build actions ensuring acknowledgement happens before applying the new sandbox policy, - // so downstream policy-change hooks don't re-trigger the warning. - let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals), - // to avoid duplicate warnings from the ensuing policy change. - if preset.is_some() { - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); - } - if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); - } - - let mut accept_and_remember_actions: Vec = Vec::new(); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); - tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); - })); - if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); - } - - let items = vec![ - SelectionItem { - name: "Continue".to_string(), - description: Some(format!("Apply {mode_label} for this session")), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Continue and don't warn again".to_string(), - description: Some(format!("Enable {mode_label} and remember this choice")), - actions: accept_and_remember_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - _preset: Option, - _sample_paths: Vec, - _extra_count: usize, - _failed_scan: bool, - ) { - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { - use ratatui_macros::line; - - if !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { - // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it - // directly (no elevation prompts). - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], - line!["Learn more: https://developers.openai.com/codex/windows"], - ]) - .wrap(Wrap { trim: false }), - )); - - let preset_clone = preset; - let items = vec![ - SelectionItem { - name: "Enable experimental sandbox".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Go back".to_string(), - description: None, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - return; - } - - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let presets = builtin_approval_presets(); - let stay_full_access = presets - .iter() - .find(|preset| preset.id == "full-access") - .is_some_and(|preset| { - Self::preset_matches_current(current_approval, current_sandbox, preset) - }); - let stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Set Up Agent Sandbox".bold()], - line![""], - line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."], - line!["Learn more: https://developers.openai.com/codex/windows"], - ]) - .wrap(Wrap { trim: false }), - )); - - let items = vec![ - SelectionItem { - name: "Set up agent sandbox (requires elevation)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: stay_label, - description: None, - actions: stay_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt( - &mut self, - preset: ApprovalPreset, - reason: WindowsSandboxFallbackReason, - ) { - use ratatui_macros::line; - - let _ = reason; - - let current_approval = self.config.approval_policy.value(); - let current_sandbox = self.config.sandbox_policy.get(); - let presets = builtin_approval_presets(); - let stay_full_access = presets - .iter() - .find(|preset| preset.id == "full-access") - .is_some_and(|preset| { - Self::preset_matches_current(current_approval, current_sandbox, preset) - }); - let stay_actions = if stay_full_access { - Vec::new() - } else { - presets - .iter() - .find(|preset| preset.id == "read-only") - .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) - }) - .unwrap_or_default() - }; - let stay_label = if stay_full_access { - "Stay in Agent Full Access".to_string() - } else { - "Stay in Read-Only".to_string() - }; - - let mut lines = Vec::new(); - lines.push(line!["Use Non-Elevated Sandbox?".bold()]); - lines.push(line![""]); - lines.push(line![ - "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." - ]); - lines.push(line![ - "Learn more: https://developers.openai.com/codex/windows" - ]); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - - let elevated_preset = preset.clone(); - let legacy_preset = preset; - let items = vec![ - SelectionItem { - name: "Try elevated agent sandbox setup again".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: elevated_preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Use non-elevated agent sandbox".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: legacy_preset.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: stay_label, - description: None, - actions: stay_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt( - &mut self, - _preset: ApprovalPreset, - _reason: WindowsSandboxFallbackReason, - ) { - } - - #[cfg(target_os = "windows")] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { - if self.config.forced_auto_mode_downgraded_on_windows - && codex_core::get_platform_sandbox().is_none() - && let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - { - self.open_windows_sandbox_enable_prompt(preset); - } - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) { - // While elevated sandbox setup runs, prevent typing so the user doesn't - // accidentally queue messages that will run under an unexpected mode. - self.bottom_pane.set_composer_input_enabled( - false, - Some("Input disabled until setup completes.".to_string()), - ); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane.set_interrupt_hint_visible(false); - self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string()); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { - self.bottom_pane.set_composer_input_enabled(true, None); - self.bottom_pane.hide_status_indicator(); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) {} - - /// Set the approval policy in the widget's config copy. - pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { - if let Err(err) = self.config.approval_policy.set(policy) { - tracing::warn!(%err, "failed to set approval_policy on chat config"); - } - } - - /// Set the sandbox policy in the widget's config copy. - pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) -> ConstraintResult<()> { - #[cfg(target_os = "windows")] - let should_clear_downgrade = !matches!(&policy, SandboxPolicy::ReadOnly) - || codex_core::get_platform_sandbox().is_some(); - - self.config.sandbox_policy.set(policy)?; - - #[cfg(target_os = "windows")] - if should_clear_downgrade { - self.config.forced_auto_mode_downgraded_on_windows = false; - } - - Ok(()) - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { - if enabled { - self.config.features.enable(feature); - } else { - self.config.features.disable(feature); - } - if feature == Feature::Steer { - self.bottom_pane.set_steer_enabled(enabled); - } else if feature == Feature::CollaborationModes { - self.bottom_pane.set_collaboration_modes_enabled(enabled); - } else if feature == Feature::Connectors { - self.bottom_pane.set_connectors_enabled(enabled); - } - } - - pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_full_access_warning = Some(acknowledged); - } - - pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_world_writable_warning = Some(acknowledged); - } - - pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { - self.config.notices.hide_rate_limit_model_nudge = Some(hidden); - if hidden { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn world_writable_warning_hidden(&self) -> bool { - self.config - .notices - .hide_world_writable_warning - .unwrap_or(false) - } - - /// Set the reasoning effort in the widget's config copy. - pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.config.model_reasoning_effort = effort; - } - - /// Set the model in the widget's config copy. - pub(crate) fn set_model(&mut self, model: &str) { - self.session_header.set_model(model); - self.model = Some(model.to_string()); - } - - fn current_model(&self) -> Option<&str> { - self.model.as_deref() - } - - fn collaboration_modes_enabled(&self) -> bool { - self.config.features.enabled(Feature::CollaborationModes) - } - - fn connectors_enabled(&self) -> bool { - self.config.features.enabled(Feature::Connectors) - } - - fn model_display_name(&self) -> &str { - self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) - } - - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - - let next = self.collaboration_mode.next(); - self.set_collaboration_mode(next); - } - - /// Update the selected collaboration mode. - /// - /// When collaboration modes are enabled, the current selection is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - fn set_collaboration_mode(&mut self, selection: CollaborationModeSelection) { - if !self.collaboration_modes_enabled() { - return; - } - - self.collaboration_mode = selection; - let flash = collaboration_modes::flash_line(selection); - const FLASH_DURATION: Duration = Duration::from_secs(2); - self.bottom_pane.flash_footer_hint(flash, FLASH_DURATION); - self.request_redraw(); - } - - /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell(config: &Config) -> Box { - let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); - Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( - DEFAULT_MODEL_DISPLAY_NAME.to_string(), - placeholder_style, - None, - config.cwd.clone(), - CODEX_CLI_VERSION, - )) - } - - /// Merge the real session info cell with any placeholder header to avoid double boxes. - fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) { - let mut session_info_cell = Some(Box::new(cell) as Box); - let merged_header = if let Some(active) = self.active_cell.take() { - if active - .as_any() - .is::() - { - if let Some(cell) = session_info_cell.take() { - self.active_cell = Some(cell); - } - true - } else { - self.active_cell = Some(active); - false - } - } else { - false - }; - - self.flush_active_cell(); - - if !merged_header && let Some(cell) = session_info_cell { - self.add_boxed_history(cell); - } - } - - pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { - self.add_to_history(history_cell::new_info_event(message, hint)); - self.request_redraw(); - } - - pub(crate) fn add_plain_history_lines(&mut self, lines: Vec>) { - self.add_boxed_history(Box::new(PlainHistoryCell::new(lines))); - self.request_redraw(); - } - - pub(crate) fn add_error_message(&mut self, message: String) { - self.add_to_history(history_cell::new_error_event(message)); - self.request_redraw(); - } - - pub(crate) fn add_mcp_output(&mut self) { - if self.config.mcp_servers.is_empty() { - self.add_to_history(history_cell::empty_mcp_output()); - } else { - self.submit_op(Op::ListMcpTools); - } - } - - pub(crate) fn add_connectors_output(&mut self) { - if !self.connectors_enabled() { - self.add_info_message( - "Apps are disabled.".to_string(), - Some("Enable the connectors feature to use /apps.".to_string()), - ); - return; - } - - if matches!(self.connectors_cache, ConnectorsCacheState::Uninitialized) { - self.prefetch_connectors(); - } - let connectors_snapshot = match &self.connectors_cache { - ConnectorsCacheState::Ready(snapshot) => Some(snapshot.clone()), - ConnectorsCacheState::Failed(err) => { - self.add_to_history(history_cell::new_error_event(err.clone())); - None - } - ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { - self.add_to_history(history_cell::new_info_event( - "Apps are still loading.".to_string(), - Some("Try again in a moment.".to_string()), - )); - None - } - }; - if let Some(snapshot) = connectors_snapshot { - if snapshot.connectors.is_empty() { - self.add_info_message("No apps available.".to_string(), None); - } else { - self.open_connectors_popup(&snapshot.connectors); - } - } - self.request_redraw(); - } - - fn open_connectors_popup(&mut self, connectors: &[connectors::ConnectorInfo]) { - let mut items: Vec = Vec::with_capacity(connectors.len()); - for connector in connectors { - let connector_label = connectors::connector_display_label(connector); - let description = Self::connector_brief_description(connector); - let search_value = format!("{connector_label} {}", connector.connector_id); - let mut item = SelectionItem { - name: connector_label.clone(), - description: Some(description), - search_value: Some(search_value), - ..Default::default() - }; - if connector.is_accessible { - let insert_text = format!("{connector_label} connector"); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::InsertComposerText(insert_text.clone())); - })]; - item.actions = actions; - item.dismiss_on_select = true; - } else if let Some(install_url) = connector.install_url.clone() { - item.actions = vec![Box::new(move |tx| { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_install_instructions_event(install_url.clone()), - ))); - })]; - item.dismiss_on_select = true; - item.selected_description = - Some("Press Enter to view the install link.".to_string()); - } else { - item.actions = vec![Box::new(|tx| { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event("Install link unavailable.".to_string(), None), - ))); - })]; - item.dismiss_on_select = true; - item.selected_description = Some("Install link unavailable.".to_string()); - } - items.push(item); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Apps".to_string()), - subtitle: Some("Select an app to insert it or view install instructions.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search apps".to_string()), - ..Default::default() - }); - } - - fn connector_brief_description(connector: &connectors::ConnectorInfo) -> String { - let status_label = if connector.is_accessible { - "Connected" - } else { - "Can be installed" - }; - match Self::connector_description(connector) { - Some(description) => format!("{status_label} · {description}"), - None => status_label.to_string(), - } - } - - fn connector_description(connector: &connectors::ConnectorInfo) -> Option { - connector - .connector_description - .as_deref() - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) - } - - /// Forward file-search results to the bottom pane. - pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { - self.bottom_pane.on_file_search_result(query, matches); - } - - /// Handles a Ctrl+C press at the chat-widget layer. - /// - /// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom - /// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut - /// is armed. - /// - /// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first - /// quit. - fn on_ctrl_c(&mut self) { - let key = key_hint::ctrl(KeyCode::Char('c')); - let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active(); - if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { - if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if modal_or_popup_active { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.bottom_pane.clear_quit_shortcut_hint(); - } else { - self.arm_quit_shortcut(key); - } - } - return; - } - - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if self.is_cancellable_work_active() { - self.submit_op(Op::Interrupt); - } else { - self.request_quit_without_confirmation(); - } - return; - } - - if self.quit_shortcut_active_for(key) { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.request_quit_without_confirmation(); - return; - } - - self.arm_quit_shortcut(key); - - if self.is_cancellable_work_active() { - self.submit_op(Op::Interrupt); - } - } - - /// Handles a Ctrl+D press at the chat-widget layer. - /// - /// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active. - /// Otherwise it should be routed to the active view and not attempt to quit. - fn on_ctrl_d(&mut self) -> bool { - let key = key_hint::ctrl(KeyCode::Char('d')); - if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED { - if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() - { - return false; - } - - self.request_quit_without_confirmation(); - return true; - } - - if self.quit_shortcut_active_for(key) { - self.quit_shortcut_expires_at = None; - self.quit_shortcut_key = None; - self.request_quit_without_confirmation(); - return true; - } - - if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() { - return false; - } - - self.arm_quit_shortcut(key); - true - } - - /// True if `key` matches the armed quit shortcut and the window has not expired. - fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool { - self.quit_shortcut_key == Some(key) - && self - .quit_shortcut_expires_at - .is_some_and(|expires_at| Instant::now() < expires_at) - } - - /// Arm the double-press quit shortcut and show the footer hint. - /// - /// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since - /// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether - /// quitting is currently allowed, while delegating rendering to `BottomPane`. - fn arm_quit_shortcut(&mut self, key: KeyBinding) { - self.quit_shortcut_expires_at = Instant::now() - .checked_add(QUIT_SHORTCUT_TIMEOUT) - .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = Some(key); - self.bottom_pane.show_quit_shortcut_hint(key); - } - - // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. - fn is_cancellable_work_active(&self) -> bool { - self.bottom_pane.is_task_running() || self.is_review_mode - } - - pub(crate) fn composer_is_empty(&self) -> bool { - self.bottom_pane.composer_is_empty() - } - - /// True when the UI is in the regular composer state with no running task, - /// no modal overlay (e.g. approvals or status indicator), and no composer popups. - /// In this state Esc-Esc backtracking is enabled. - pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - self.bottom_pane.is_normal_backtrack_mode() - } - - pub(crate) fn insert_str(&mut self, text: &str) { - self.bottom_pane.insert_str(text); - } - - /// Replace the composer content with the provided text and reset cursor. - pub(crate) fn set_composer_text(&mut self, text: String) { - self.bottom_pane.set_composer_text(text); - } - - /// Insert text into the composer at the current cursor position. - pub(crate) fn insert_composer_text(&mut self, text: &str) { - self.bottom_pane.insert_str(text); - } - - pub(crate) fn show_esc_backtrack_hint(&mut self) { - self.bottom_pane.show_esc_backtrack_hint(); - } - - pub(crate) fn clear_esc_backtrack_hint(&mut self) { - self.bottom_pane.clear_esc_backtrack_hint(); - } - - /// Return true when the bottom pane currently has an active task. - /// - /// This is used by the viewport to decide when mouse selections should - /// disengage auto-follow behavior while responses are streaming. - pub(crate) fn is_task_running(&self) -> bool { - self.bottom_pane.is_task_running() - } - - /// Inform the bottom pane about the current transcript scroll state. - /// - /// This is used by the footer to surface when the inline transcript is - /// scrolled away from the bottom and to display the current - /// `(visible_top, total)` scroll position alongside other shortcuts. - pub(crate) fn set_transcript_ui_state( - &mut self, - scrolled: bool, - selection_active: bool, - scroll_position: Option<(usize, usize)>, - copy_selection_key: crate::key_hint::KeyBinding, - copy_feedback: Option, - ) { - self.bottom_pane.set_transcript_ui_state( - scrolled, - selection_active, - scroll_position, - copy_selection_key, - copy_feedback, - ); - } - - /// Forward an `Op` directly to codex. - pub(crate) fn submit_op(&self, op: Op) { - // Record outbound operation for session replay fidelity. - crate::session_log::log_outbound_op(&op); - if let Err(e) = self.codex_op_tx.send(op) { - tracing::error!("failed to submit op: {e}"); - } - } - - fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - ev.resources, - ev.resource_templates, - &ev.auth_statuses, - )); - } - - fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { - let len = ev.custom_prompts.len(); - debug!("received {len} custom prompts"); - // Forward to bottom pane so the slash popup can show them now. - self.bottom_pane.set_custom_prompts(ev.custom_prompts); - } - - fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { - self.set_skills_from_response(&ev); - } - - pub(crate) fn on_connectors_loaded(&mut self, result: Result) { - self.connectors_cache = match result { - Ok(connectors) => ConnectorsCacheState::Ready(connectors), - Err(err) => ConnectorsCacheState::Failed(err), - }; - } - - pub(crate) fn open_review_popup(&mut self) { - let mut items: Vec = Vec::new(); - - items.push(SelectionItem { - name: "Review against a base branch".to_string(), - description: Some("(PR Style)".into()), - actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); - move |tx| { - tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - ..Default::default() - }); - - items.push(SelectionItem { - name: "Review uncommitted changes".to_string(), - actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - ..Default::default() - }); - - // New: Review a specific commit (opens commit picker) - items.push(SelectionItem { - name: "Review a commit".to_string(), - actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); - move |tx| { - tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); - } - })], - dismiss_on_select: false, - ..Default::default() - }); - - items.push(SelectionItem { - name: "Custom review instructions".to_string(), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenReviewCustomPrompt); - })], - dismiss_on_select: false, - ..Default::default() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a review preset".into()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) { - let branches = local_git_branches(cwd).await; - let current_branch = current_branch_name(cwd) - .await - .unwrap_or_else(|| "(detached HEAD)".to_string()); - let mut items: Vec = Vec::with_capacity(branches.len()); - - for option in branches { - let branch = option.clone(); - items.push(SelectionItem { - name: format!("{current_branch} -> {branch}"), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: branch.clone(), - }, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - search_value: Some(option), - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a base branch".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search branches".to_string()), - ..Default::default() - }); - } - - pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = codex_core::git_info::recent_commits(cwd, 100).await; - - let mut items: Vec = Vec::with_capacity(commits.len()); - for entry in commits { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); - } - - pub(crate) fn show_review_custom_prompt(&mut self) { - let tx = self.app_event_tx.clone(); - let view = CustomPromptView::new( - "Custom review instructions".to_string(), - "Type instructions and press Enter".to_string(), - None, - Box::new(move |prompt: String| { - let trimmed = prompt.trim().to_string(); - if trimmed.is_empty() { - return; - } - tx.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Custom { - instructions: trimmed, - }, - user_facing_hint: None, - }, - })); - }), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - pub(crate) fn token_usage(&self) -> TokenUsage { - self.token_info - .as_ref() - .map(|ti| ti.total_token_usage.clone()) - .unwrap_or_default() - } - - pub(crate) fn conversation_id(&self) -> Option { - self.conversation_id - } - - pub(crate) fn rollout_path(&self) -> Option { - self.current_rollout_path.clone() - } - - fn is_session_configured(&self) -> bool { - self.conversation_id.is_some() - } - - /// Returns a cache key describing the current in-flight active cell for the transcript overlay. - /// - /// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the - /// current active cell, and the overlay caches that tail; this key is what it uses to decide - /// whether it must recompute. When there is no active cell, this returns `None` so the overlay - /// can drop the tail entirely. - /// - /// If callers mutate the active cell's transcript output without bumping the revision (or - /// providing an appropriate animation tick), the overlay will keep showing a stale tail while - /// the main viewport updates. - pub(crate) fn active_cell_transcript_key(&self) -> Option { - let cell = self.active_cell.as_ref()?; - Some(ActiveCellTranscriptKey { - revision: self.active_cell_revision, - is_stream_continuation: cell.is_stream_continuation(), - animation_tick: cell.transcript_animation_tick(), - }) - } - - /// Returns the active cell's transcript lines for a given terminal width. - /// - /// This is a convenience for the transcript overlay live-tail path, and it intentionally - /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers - /// should pass the same width the overlay uses; using a different width will cause wrapping - /// mismatches between the main viewport and the transcript overlay. - pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { - let cell = self.active_cell.as_ref()?; - let lines = cell.transcript_lines(width); - (!lines.is_empty()).then_some(lines) - } - - /// Return a reference to the widget's current config (includes any - /// runtime overrides applied via TUI, e.g., model or approval policy). - pub(crate) fn config_ref(&self) -> &Config { - &self.config - } - - pub(crate) fn clear_token_usage(&mut self) { - self.token_info = None; - } - - fn as_renderable(&self) -> RenderableItem<'_> { - let active_cell_renderable = match &self.active_cell { - Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr(1, 0, 0, 0)), - None => RenderableItem::Owned(Box::new(())), - }; - let mut flex = FlexRenderable::new(); - flex.push(1, active_cell_renderable); - flex.push( - 0, - RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(1, 0, 0, 0)), - ); - RenderableItem::Owned(Box::new(flex)) - } -} - -impl Drop for ChatWidget { - fn drop(&mut self) { - self.stop_rate_limit_poller(); - } -} - -impl Renderable for ChatWidget { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.as_renderable().render(area, buf); - self.last_rendered_width.set(Some(area.width as usize)); - } - - fn desired_height(&self, width: u16) -> u16 { - self.as_renderable().desired_height(width) - } - - fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.as_renderable().cursor_pos(area) - } -} - -enum Notification { - AgentTurnComplete { response: String }, - ExecApprovalRequested { command: String }, - EditApprovalRequested { cwd: PathBuf, changes: Vec }, - ElicitationRequested { server_name: String }, -} - -impl Notification { - fn display(&self) -> String { - match self { - Notification::AgentTurnComplete { response } => { - Notification::agent_turn_preview(response) - .unwrap_or_else(|| "Agent turn complete".to_string()) - } - Notification::ExecApprovalRequested { command } => { - format!("Approval requested: {}", truncate_text(command, 30)) - } - Notification::EditApprovalRequested { cwd, changes } => { - format!( - "Codex wants to edit {}", - if changes.len() == 1 { - #[allow(clippy::unwrap_used)] - display_path_for(changes.first().unwrap(), cwd) - } else { - format!("{} files", changes.len()) - } - ) - } - Notification::ElicitationRequested { server_name } => { - format!("Approval requested by {server_name}") - } - } - } - - fn type_name(&self) -> &str { - match self { - Notification::AgentTurnComplete { .. } => "agent-turn-complete", - Notification::ExecApprovalRequested { .. } - | Notification::EditApprovalRequested { .. } - | Notification::ElicitationRequested { .. } => "approval-requested", - } - } - - fn allowed_for(&self, settings: &Notifications) -> bool { - match settings { - Notifications::Enabled(enabled) => *enabled, - Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), - } - } - - fn agent_turn_preview(response: &str) -> Option { - let mut normalized = String::new(); - for part in response.split_whitespace() { - if !normalized.is_empty() { - normalized.push(' '); - } - normalized.push_str(part); - } - let trimmed = normalized.trim(); - if trimmed.is_empty() { - None - } else { - Some(truncate_text(trimmed, AGENT_NOTIFICATION_PREVIEW_GRAPHEMES)) - } - } -} - -const AGENT_NOTIFICATION_PREVIEW_GRAPHEMES: usize = 200; - -const PLACEHOLDERS: [&str; 8] = [ - "Explain this codebase", - "Summarize recent commits", - "Implement {feature}", - "Find and fix a bug in @filename", - "Write tests for @filename", - "Improve documentation in @filename", - "Run /review on my current changes", - "Use /skills to list available skills", -]; - -// Extract the first bold (Markdown) element in the form **...** from `s`. -// Returns the inner text if found; otherwise `None`. -fn extract_first_bold(s: &str) -> Option { - let bytes = s.as_bytes(); - let mut i = 0usize; - while i + 1 < bytes.len() { - if bytes[i] == b'*' && bytes[i + 1] == b'*' { - let start = i + 2; - let mut j = start; - while j + 1 < bytes.len() { - if bytes[j] == b'*' && bytes[j + 1] == b'*' { - // Found closing ** - let inner = &s[start..j]; - let trimmed = inner.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } else { - return None; - } - } - j += 1; - } - // No closing; stop searching (wait for more deltas) - return None; - } - i += 1; - } - None -} - -async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { - match BackendClient::from_auth(base_url, &auth) { - Ok(client) => match client.get_rate_limits().await { - Ok(snapshot) => Some(snapshot), - Err(err) => { - debug!(error = ?err, "failed to fetch rate limits from /usage"); - None - } - }, - Err(err) => { - debug!(error = ?err, "failed to construct backend client for rate limits"); - None - } - } -} - -#[cfg(test)] -pub(crate) fn show_review_commit_picker_with_entries( - chat: &mut ChatWidget, - entries: Vec, -) { - let mut items: Vec = Vec::with_capacity(entries.len()); - for entry in entries { - let subject = entry.subject.clone(); - let sha = entry.sha.clone(); - let search_val = format!("{subject} {sha}"); - - items.push(SelectionItem { - name: subject.clone(), - actions: vec![Box::new(move |tx3: &AppEventSender| { - tx3.send(AppEvent::CodexOp(Op::Review { - review_request: ReviewRequest { - target: ReviewTarget::Commit { - sha: sha.clone(), - title: Some(subject.clone()), - }, - user_facing_hint: None, - }, - })); - })], - dismiss_on_select: true, - search_value: Some(search_val), - ..Default::default() - }); - } - - chat.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select a commit to review".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search commits".to_string()), - ..Default::default() - }); -} - -fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut matches: Vec = Vec::new(); - for skill in skills { - if seen.contains(&skill.name) { - continue; - } - let needle = format!("${}", skill.name); - if text.contains(&needle) { - seen.insert(skill.name.clone()); - matches.push(skill.clone()); - } - } - matches -} - -fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec { - skills_entries - .iter() - .find(|entry| entry.cwd.as_path() == cwd) - .map(|entry| { - entry - .skills - .iter() - .map(|skill| SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill.interface.clone().map(|interface| SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - }), - path: skill.path.clone(), - scope: skill.scope, - }) - .collect() - }) - .unwrap_or_default() -} - -#[cfg(test)] -pub(crate) mod tests; diff --git a/codex-rs/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs deleted file mode 100644 index 0cfda7f342..0000000000 --- a/codex-rs/tui2/src/chatwidget/agent.rs +++ /dev/null @@ -1,107 +0,0 @@ -use std::sync::Arc; - -use codex_core::CodexThread; -use codex_core::NewThread; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; -use tokio::sync::mpsc::UnboundedSender; -use tokio::sync::mpsc::unbounded_channel; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -/// Spawn the agent bootstrapper and op forwarding loop, returning the -/// `UnboundedSender` used by the UI to submit operations. -pub(crate) fn spawn_agent( - config: Config, - app_event_tx: AppEventSender, - server: Arc, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - let NewThread { - thread, - session_configured, - thread_id: _, - } = match server.start_thread(config).await { - Ok(v) => v, - Err(err) => { - let message = format!("Failed to initialize codex: {err}"); - tracing::error!("{message}"); - app_event_tx_clone.send(AppEvent::CodexEvent(Event { - id: "".to_string(), - msg: EventMsg::Error(err.to_error_event(None)), - })); - app_event_tx_clone.send(AppEvent::FatalExitRequest(message)); - tracing::error!("failed to initialize codex: {err}"); - return; - } - }; - - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - // The `id` does not matter for rendering, so we can use a fake value. - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = thread.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} - -/// Spawn agent loops for an existing thread (e.g., a forked thread). -/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent -/// events and accepts Ops for submission. -pub(crate) fn spawn_agent_from_existing( - thread: std::sync::Arc, - session_configured: codex_core::protocol::SessionConfiguredEvent, - app_event_tx: AppEventSender, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let thread_clone = thread.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = thread_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = thread.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} diff --git a/codex-rs/tui2/src/chatwidget/interrupts.rs b/codex-rs/tui2/src/chatwidget/interrupts.rs deleted file mode 100644 index dc1e683ea5..0000000000 --- a/codex-rs/tui2/src/chatwidget/interrupts.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::collections::VecDeque; - -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::McpToolCallBeginEvent; -use codex_core::protocol::McpToolCallEndEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_protocol::approvals::ElicitationRequestEvent; - -use super::ChatWidget; - -#[derive(Debug)] -pub(crate) enum QueuedInterrupt { - ExecApproval(String, ExecApprovalRequestEvent), - ApplyPatchApproval(String, ApplyPatchApprovalRequestEvent), - Elicitation(ElicitationRequestEvent), - ExecBegin(ExecCommandBeginEvent), - ExecEnd(ExecCommandEndEvent), - McpBegin(McpToolCallBeginEvent), - McpEnd(McpToolCallEndEvent), - PatchEnd(PatchApplyEndEvent), -} - -#[derive(Default)] -pub(crate) struct InterruptManager { - queue: VecDeque, -} - -impl InterruptManager { - pub(crate) fn new() -> Self { - Self { - queue: VecDeque::new(), - } - } - - #[inline] - pub(crate) fn is_empty(&self) -> bool { - self.queue.is_empty() - } - - pub(crate) fn push_exec_approval(&mut self, id: String, ev: ExecApprovalRequestEvent) { - self.queue.push_back(QueuedInterrupt::ExecApproval(id, ev)); - } - - pub(crate) fn push_apply_patch_approval( - &mut self, - id: String, - ev: ApplyPatchApprovalRequestEvent, - ) { - self.queue - .push_back(QueuedInterrupt::ApplyPatchApproval(id, ev)); - } - - pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) { - self.queue.push_back(QueuedInterrupt::Elicitation(ev)); - } - - pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) { - self.queue.push_back(QueuedInterrupt::ExecBegin(ev)); - } - - pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) { - self.queue.push_back(QueuedInterrupt::ExecEnd(ev)); - } - - pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) { - self.queue.push_back(QueuedInterrupt::McpBegin(ev)); - } - - pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) { - self.queue.push_back(QueuedInterrupt::McpEnd(ev)); - } - - pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) { - self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); - } - - pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { - while let Some(q) = self.queue.pop_front() { - match q { - QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), - QueuedInterrupt::ApplyPatchApproval(id, ev) => { - chat.handle_apply_patch_approval_now(id, ev) - } - QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev), - QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev), - QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev), - QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev), - QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev), - QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev), - } - } - } -} diff --git a/codex-rs/tui2/src/chatwidget/session_header.rs b/codex-rs/tui2/src/chatwidget/session_header.rs deleted file mode 100644 index 32e31b6682..0000000000 --- a/codex-rs/tui2/src/chatwidget/session_header.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub(crate) struct SessionHeader { - model: String, -} - -impl SessionHeader { - pub(crate) fn new(model: String) -> Self { - Self { model } - } - - /// Updates the header's model text. - pub(crate) fn set_model(&mut self, model: &str) { - if self.model != model { - self.model = model.to_string(); - } - } -} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap deleted file mode 100644 index 26c7f58709..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&approved_lines) ---- -• Added foo.txt (+1 -0) - 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap deleted file mode 100644 index c69730b483..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - Would you like to run the following command? - - Reason: this is a test reason such as one that would be produced by the model - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap deleted file mode 100644 index 962a38ebde..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: contents ---- - - - Would you like to run the following command? - - $ python - <<'PY' - print('hello') - PY - -› 1. Yes, proceed (y) - 2. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap deleted file mode 100644 index ab469f34b6..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - Would you like to run the following command? - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap deleted file mode 100644 index c2ec167506..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - - - Would you like to make the following edits? - - Reason: The model wants to apply changes - - README.md (+2 -0) - - 1 +hello - 2 +world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for these files (a) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap deleted file mode 100644 index 46ec74d117..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap deleted file mode 100644 index 5024b90a62..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back - diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap deleted file mode 100644 index bd6b8343ed..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -assertion_line: 1773 -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run - commands. - 2. Agent (non-elevated sandbox) Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace - and run commands with network access. - Exercise caution when using. - - The non-elevated sandbox protects your files and prevents network access under - most circumstances. However, it carries greater risk if prompt injected. To - upgrade to the elevated sandbox, run /setup-elevated-sandbox. - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap deleted file mode 100644 index 8900e83d9a..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap deleted file mode 100644 index a2afe14dfa..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap deleted file mode 100644 index 1b285fb810..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap deleted file mode 100644 index 8900e83d9a..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap deleted file mode 100644 index a2afe14dfa..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap deleted file mode 100644 index 1b285fb810..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap deleted file mode 100644 index c7d5535723..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- - - - - - - - - - - - - - - - - - - - - - - - - - - - -• I’m 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 - - 100% context left · tab to queue message diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap deleted file mode 100644 index 9ab9b03380..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; - - ```sh - printf 'fenced within fenced\n' - ``` - - { - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" - } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap deleted file mode 100644 index 3cc0b593d4..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• Working (0s • esc to interrupt) - ↳ Hello, world! 0 - ↳ Hello, world! 1 - ↳ Hello, world! 2 - ↳ Hello, world! 3 - ↳ Hello, world! 4 - ↳ Hello, world! 5 - ↳ Hello, world! 6 - ↳ Hello, world! 7 - ↳ Hello, world! 8 - ↳ Hello, world! 9 - ↳ Hello, world! 10 - ↳ Hello, world! 11 - ↳ Hello, world! 12 - ↳ Hello, world! 13 - ↳ Hello, world! 14 - ↳ Hello, world! 15 - ↳ Hello, world! 16 - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap deleted file mode 100644 index 3d83bdb0f5..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index 6d252a0d3e..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: blob ---- -■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap deleted file mode 100644 index 50c0828773..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&aborted_long) ---- -✗ You canceled the request to run echo - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap deleted file mode 100644 index d7e1e2ac3a..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&aborted_multi) ---- -✗ You canceled the request to run echo line1 ... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap deleted file mode 100644 index 2d3767dffb..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: lines_to_single_string(&decision) ---- -✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap deleted file mode 100644 index 93451be714..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 13 }, - content: [ - " ", - " ", - " Would you like to run the following command? ", - " ", - " Reason: this is a test reason such as one that would be produced by the ", - " model ", - " ", - " $ echo hello world ", - " ", - "› 1. Yes, proceed (y) ", - " 2. No, and tell Codex what to do differently (esc) ", - " ", - " Press enter to confirm or esc to cancel ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - ] -} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap deleted file mode 100644 index 7a20304601..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Exploring - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap deleted file mode 100644 index b13ce510e0..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap deleted file mode 100644 index ab15a80ff3..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Exploring - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap deleted file mode 100644 index 21b41860fc..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap deleted file mode 100644 index 21b41860fc..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap deleted file mode 100644 index a38d4c7fd2..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: active_blob(&chat) ---- -• Explored - └ List ls -la - Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap deleted file mode 100644 index 52ce03bbea..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - How was this? - -› 1. bug Crash, error message, hang, or broken UI/behavior. - 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. - 3. good result Helpful, correct, high‑quality, or delightful result worth - celebrating. - 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap deleted file mode 100644 index 21d031df6c..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Upload logs? - - The following files will be sent: - • codex-logs.log - -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. - 2. No - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap deleted file mode 100644 index 3d83bdb0f5..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap deleted file mode 100644 index f7a2b3dcb6..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Enable full access? - When Codex runs with full access, it can edit any file on your computer and - run commands with network, without your approval. Exercise caution when - enabling full access. This significantly increases the risk of data loss, - leaks, or unexpected behavior. - -› 1. Yes, continue anyway Apply full access for this session - 2. Yes, and don't ask again Enable full access and remember this choice - 3. Cancel Go back without enabling full access - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap deleted file mode 100644 index 3863f9a8d5..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: exec_blob ---- -• Ran sleep 1 - └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap deleted file mode 100644 index 943fe34440..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: last ---- -■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap deleted file mode 100644 index 31c5e74b0a..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: combined ---- -• Viewed Image - └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__mcp_startup_header_booting.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__mcp_startup_header_booting.snap deleted file mode 100644 index e16f50ba1b..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__mcp_startup_header_booting.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -"• Booting MCP server: alpha (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_picker_filters_hidden_models.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_picker_filters_hidden_models.snap deleted file mode 100644 index a03d434905..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_picker_filters_hidden_models.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -assertion_line: 1758 -expression: popup ---- - Select Model and Effort - Access legacy models by running codex -m or in your config.toml - -› 1. test-visible-model (current) test-visible-model description - - Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap deleted file mode 100644 index 86c748d597..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap deleted file mode 100644 index 4d1d62d935..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday - tasks - 3. High Greater reasoning depth for complex problems -› 4. Extra high (current) Extra high reasoning depth for complex problems - ⚠ Extra high reasoning effort can quickly consume - Plus plan rate limits. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap deleted file mode 100644 index 190f9c2933..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Select Model and Effort - Access legacy models by running codex -m or in your config.toml - -› 1. gpt-5.2-codex (default) Latest frontier agentic coding model. - 2. gpt-5.1-codex-max Codex-optimized flagship for deep and fast - reasoning. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.2 Latest frontier model with improvements across - knowledge, reasoning and coding - - Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap deleted file mode 100644 index d553957350..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: popup ---- - Approaching rate limits - Switch to gpt-5.1-codex-mini for lower credit usage? - -› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, - faster, but less capable. - 2. Keep current model - 3. Keep current model (never show again) Hide future rate limit reminders - about switching models. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap deleted file mode 100644 index f761e5730b..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -"• Analyzing (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap deleted file mode 100644 index 567794cea6..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " -" 3. No, and tell Codex what to do differently (esc) " -" " -" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap deleted file mode 100644 index 3a9f08ab94..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui2/src/chatwidget/tests.rs -expression: blob ---- -• You ran ls - └ file1 - file2 diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap deleted file mode 100644 index e139b51088..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: lines_to_single_string(&approved_lines) ---- -• Added foo.txt (+1 -0) - 1 +hello diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap deleted file mode 100644 index 15511611a1..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - - - Would you like to run the following command? - - Reason: this is a test reason such as one that would be produced by the model - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap deleted file mode 100644 index 2bbe9aefcd..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - Would you like to run the following command? - - $ echo hello world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for commands that start with `echo hello world` (p) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap deleted file mode 100644 index e394605dcc..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - - - Would you like to make the following edits? - - Reason: The model wants to apply changes - - README.md (+2 -0) - - 1 +hello - 2 +world - -› 1. Yes, proceed (y) - 2. Yes, and don't ask again for these files (a) - 3. No, and tell Codex what to do differently (esc) - - Press enter to confirm or esc to cancel diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap deleted file mode 100644 index 6758ec62c5..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap deleted file mode 100644 index 6758ec62c5..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Approval Mode - -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap deleted file mode 100644 index 77738439a1..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap +++ /dev/null @@ -1,153 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: "lines[start_idx..].join(\"\\n\")" ---- -• I need to check the codex-rs repository to explain why the project's binaries - are large. The user is likely seeking specifics about the setup: are Rust - builds static, what features are enabled, and is debug information included? - It could be due to static linking, included OpenSSL, or how panic handling - is set up. I should look into the Cargo.toml file to confirm features and - profiles without needing to edit any code. Let's get started on this! - -─ Worked for 0s ──────────────────────────────────────────────────────────────── - -• I’m going to scan the workspace and Cargo manifests to see build profiles and - dependencies that impact binary size. Then I’ll summarize the main causes. - -• Explored - └ List ls -la - Read Cargo.toml - -• I'm reviewing the workspace's release profile, which has settings like - lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it - seems that "strip = symbols" only removes symbols, not debug information. In - Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I - should check whether using these results in a smaller binary. I’ll also skim - through crates for heavy dependencies in Cargo.toml, including cli, core, - exec, linux-sandbox, tui, login, ollama, and mcp. - -• 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; - │ … +1 lines - └ --- ansi-escape/Cargo.toml - [package] - … +243 lines - ] } - tracing = { version - -• Explored - └ Read Cargo.toml - -• I’m evaluating how binary size is influenced by various factors. There are - many binaries, such as codex and codex-exec, which include dependencies by - default, pulling in features like Tokio and hyper. For instance, reqwest - includes default features like TLS through rustls. If default features remain - enabled, it inflates the size with many additional components. I notice - that for musl targets, using vendored builds of OpenSSL could significantly - increase binary size. This is quite the intricate web of dependencies! - - Analyzing musl and binary size - - I’m considering the implications of building for musl with static linking. - In this case, OpenSSL might come into play through some crates, but reqwest - defaults to using rustls, although openssl-sys vendored could increase - binary size. It’s interesting to think about whether they’re targeting musl, - especially since it seems they might often be building for Mac and Linux with - dynamic linking instead. - - I also realize there’s a difference between debug and release builds — release - should be smaller due to optimizations, but if they’re defaulting to debug - builds, that could lead to much larger binaries. - - Assessing crate impacts on size - - I need to be precise when analyzing binary sizes from various crates. Some - crates, like ratatui-image, include heavy features such as libjpeg decoders, - which can really bulk things up. Then there are the tree-sitter crates that - contain large static grammar tables, adding significant size. The starlark - crate and tracing-subscriber also contribute to size increases. - - With static linking per binary being standard in Rust, it leads to multiple - copies of the same dependencies unless cdylibs are used. That makes the - compile times longer, but using LTO helps reduce bloat somewhat through - deduplication. - - Evaluating binary size factors - - Having multiple binaries indeed increases the overall size significantly. - I'm considering options like upgrading to "opt-level = 'z'" or using - "strip=debuginfo" for reducing size. It's interesting that while the profile - only sets symbols for stripping, not debuginfo, this could inflate sizes if - retained in release builds. - - The distinction between "symbols" and "debuginfo" is critical; "symbols" - keeps debug information which may not align with the goal of minimizing - size. I’d like to verify with the Rust documentation, but my network access - is restricted. - - Explaining large binary sizes - - I’m analyzing why binaries in this project are so large. The primary reasons - include monolithic static linking, where each binary includes the full - dependency graph without shared libraries. Heavy dependencies such as reqwest, - hyper, and TLS components also contribute significantly to size, along with - image decoders and other libraries. - - Using MUSL targets means vendored OpenSSL can drastically increase the - size due to static linking. Furthermore, if the release profile keeps debug - information due to using "strip = symbols," that can lead to larger binaries - as well. It’s a mix of several factors, including panic strategies and - platform-specific linking behavior. - - Identifying binary size contributors - - I’m considering why binaries are still large even without static linking. The - tracing-subscriber crate potentially adds weight because it includes regex, - though it can sometimes use a lighter version. References from files like - "core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses - the image crate. - - To present this clearly, I’ll format the final answer with two main sections: - "Main Causes" and "Build-Mode Notes." I can also include brief suggestions for - reducing size, but I want to stay focused on answering the user's question. - -─ Worked for 0s ──────────────────────────────────────────────────────────────── - -• Here’s what’s driving size in this workspace’s 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 isn’t - 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. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap deleted file mode 100644 index 1e73a237eb..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap deleted file mode 100644 index 7a04b0ef19..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap deleted file mode 100644 index 4487d0652e..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap deleted file mode 100644 index 1e73a237eb..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap deleted file mode 100644 index 7a04b0ef19..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap deleted file mode 100644 index 4487d0652e..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap deleted file mode 100644 index c3bdf60bd2..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• I’m 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 - - 100% context left diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap deleted file mode 100644 index 1ed73b5fa5..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: visual ---- -• -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; - - ```sh - printf 'fenced within fenced\n' - ``` - - { - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" - } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap deleted file mode 100644 index 6d9aa515b1..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() ---- -• Working (0s • esc to interrupt) - ↳ Hello, world! 0 - ↳ Hello, world! 1 - ↳ Hello, world! 2 - ↳ Hello, world! 3 - ↳ Hello, world! 4 - ↳ Hello, world! 5 - ↳ Hello, world! 6 - ↳ Hello, world! 7 - ↳ Hello, world! 8 - ↳ Hello, world! 9 - ↳ Hello, world! 10 - ↳ Hello, world! 11 - ↳ Hello, world! 12 - ↳ Hello, world! 13 - ↳ Hello, world! 14 - ↳ Hello, world! 15 - ↳ Hello, world! 16 - - -› Ask Codex to do anything - - 100% context left · ? for shortcuts diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap deleted file mode 100644 index 6062087181..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap deleted file mode 100644 index e8f08a437a..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob ---- -■ '/model' is disabled while a task is in progress. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap deleted file mode 100644 index f04e1f078a..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 495 -expression: lines_to_single_string(&aborted_long) ---- -✗ You canceled the request to run echo - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap deleted file mode 100644 index d35cb17597..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: lines_to_single_string(&aborted_multi) ---- -✗ You canceled the request to run echo line1 ... - diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap deleted file mode 100644 index 2f0f1412a1..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: lines_to_single_string(&decision) ---- -✔ You approved codex to run echo hello world this time diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap deleted file mode 100644 index 1c6a3ef136..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 13 }, - content: [ - " ", - " ", - " Would you like to run the following command? ", - " ", - " Reason: this is a test reason such as one that would be produced by the ", - " model ", - " ", - " $ echo hello world ", - " ", - "› 1. Yes, proceed (y) ", - " 2. No, and tell Codex what to do differently (esc) ", - " ", - " Press enter to confirm or esc to cancel ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC, - x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, - x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - ] -} diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap deleted file mode 100644 index 588a9503eb..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob1 ---- -• Exploring - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap deleted file mode 100644 index 492e8b7708..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob2 ---- -• Explored - └ List ls -la diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap deleted file mode 100644 index 2ce4170929..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob3 ---- -• Exploring - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap deleted file mode 100644 index 9e29785f71..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob4 ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap deleted file mode 100644 index 296b00f905..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob5 ---- -• Explored - └ List ls -la - Read foo.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap deleted file mode 100644 index 55fa979123..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob6 ---- -• Explored - └ List ls -la - Read foo.txt, bar.txt diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap deleted file mode 100644 index 4a98242027..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - How was this? - -› 1. bug Crash, error message, hang, or broken UI/behavior. - 2. bad result Output was off-target, incorrect, incomplete, or unhelpful. - 3. good result Helpful, correct, high‑quality, or delightful result worth - celebrating. - 4. other Slowness, feature suggestion, UX feedback, or anything else. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap deleted file mode 100644 index cc3d8e3755..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Upload logs? - - The following files will be sent: - • codex-logs.log - -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. - 2. No - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap deleted file mode 100644 index 6062087181..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Here is the result. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap deleted file mode 100644 index 71dac5f590..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Enable full access? - When Codex runs with full access, it can edit any file on your computer and - run commands with network, without your approval. Exercise caution when - enabling full access. This significantly increases the risk of data loss, - leaks, or unexpected behavior. - -› 1. Yes, continue anyway Apply full access for this session - 2. Yes, and don't ask again Enable full access and remember this choice - 3. Cancel Go back without enabling full access - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap deleted file mode 100644 index 59eff20ace..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: exec_blob ---- -• Ran sleep 1 - └ (no output) diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap deleted file mode 100644 index cf4c6943fd..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Viewed Image - └ example.png diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap deleted file mode 100644 index a4a86a41ba..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday tasks -› 3. High (current) Greater reasoning depth for complex problems - 4. Extra high Extra high reasoning depth for complex problems - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap deleted file mode 100644 index 3eb172c101..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 1548 -expression: popup ---- - Select Reasoning Level for gpt-5.1-codex-max - - 1. Low Fast responses with lighter reasoning - 2. Medium (default) Balances speed and reasoning depth for everyday - tasks - 3. High Greater reasoning depth for complex problems -› 4. Extra high (current) Extra high reasoning depth for complex problems - ⚠ Extra high reasoning effort can quickly consume - Plus plan rate limits. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap deleted file mode 100644 index 56a209ef73..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Select Model and Effort - Access legacy models by running codex -m or in your config.toml - -› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast - reasoning. - 2. gpt-5.1-codex Optimized for codex. - 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less - capable. - 4. gpt-5.1 Broad world knowledge with strong general reasoning. - - Press enter to select reasoning effort, or esc to dismiss. diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap deleted file mode 100644 index e210d1f0a3..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: popup ---- - Approaching rate limits - Switch to gpt-5.1-codex-mini for lower credit usage? - -› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper, - faster, but less capable. - 2. Keep current model - 3. Keep current model (never show again) Hide future rate limit reminders - about switching models. - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap deleted file mode 100644 index 9fbebfb500..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 1577 -expression: terminal.backend() ---- -" " -"• Analyzing (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" 100% context left · ? for shortcuts " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap deleted file mode 100644 index 5e6e33dece..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " -" 3. No, and tell Codex what to do differently (esc) " -" " -" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap deleted file mode 100644 index 6a49cb253c..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend().vt100().screen().contents() ---- - ✨ New version available! Would you like to update? - - Full release notes: https://github.com/openai/codex/releases/latest - - -› 1. Yes, update now - 2. No, not now - 3. Don't remind me - - Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap deleted file mode 100644 index c67cd637d7..0000000000 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: blob ---- -• You ran ls - └ file1 - file2 diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs deleted file mode 100644 index c23d16ebbf..0000000000 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ /dev/null @@ -1,3616 +0,0 @@ -//! Exercises `ChatWidget` event handling and rendering invariants. -//! -//! These tests treat the widget as the adapter between `codex_core::protocol::EventMsg` inputs and -//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header -//! changes show up as stable, reviewable diffs. - -use super::*; -use crate::app_event::AppEvent; -use crate::app_event::ExitMode; -use crate::app_event_sender::AppEventSender; -use crate::test_backend::VT100Backend; -use crate::tui::FrameRequester; -use assert_matches::assert_matches; -use codex_common::approval_presets::builtin_approval_presets; -use codex_core::AuthManager; -use codex_core::CodexAuth; -use codex_core::config::Config; -use codex_core::config::ConfigBuilder; -use codex_core::config::Constrained; -use codex_core::features::Feature; -use codex_core::models_manager::manager::ModelsManager; -use codex_core::protocol::AgentMessageDeltaEvent; -use codex_core::protocol::AgentMessageEvent; -use codex_core::protocol::AgentReasoningDeltaEvent; -use codex_core::protocol::AgentReasoningEvent; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::BackgroundEventEvent; -use codex_core::protocol::CreditsSnapshot; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ExecCommandBeginEvent; -use codex_core::protocol::ExecCommandEndEvent; -use codex_core::protocol::ExecCommandSource; -use codex_core::protocol::ExecPolicyAmendment; -use codex_core::protocol::ExitedReviewModeEvent; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpStartupCompleteEvent; -use codex_core::protocol::McpStartupStatus; -use codex_core::protocol::McpStartupUpdateEvent; -use codex_core::protocol::Op; -use codex_core::protocol::PatchApplyBeginEvent; -use codex_core::protocol::PatchApplyEndEvent; -use codex_core::protocol::RateLimitWindow; -use codex_core::protocol::ReviewRequest; -use codex_core::protocol::ReviewTarget; -use codex_core::protocol::StreamErrorEvent; -use codex_core::protocol::TokenCountEvent; -use codex_core::protocol::TokenUsage; -use codex_core::protocol::TokenUsageInfo; -use codex_core::protocol::TurnCompleteEvent; -use codex_core::protocol::TurnStartedEvent; -use codex_core::protocol::UndoCompletedEvent; -use codex_core::protocol::UndoStartedEvent; -use codex_core::protocol::ViewImageToolCallEvent; -use codex_core::protocol::WarningEvent; -use codex_protocol::ThreadId; -use codex_protocol::account::PlanType; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::CodexErrorInfo; -use codex_utils_absolute_path::AbsolutePathBuf; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyModifiers; -use insta::assert_snapshot; -use pretty_assertions::assert_eq; -#[cfg(target_os = "windows")] -use serial_test::serial; -use std::collections::HashSet; -use std::path::PathBuf; -use tempfile::NamedTempFile; -use tempfile::tempdir; -use tokio::sync::mpsc::error::TryRecvError; -use tokio::sync::mpsc::unbounded_channel; - -#[cfg(target_os = "windows")] -fn set_windows_sandbox_enabled(enabled: bool) { - codex_core::set_windows_sandbox_enabled(enabled); -} - -#[cfg(target_os = "windows")] -fn set_windows_elevated_sandbox_enabled(enabled: bool) { - codex_core::set_windows_elevated_sandbox_enabled(enabled); -} - -async fn test_config() -> Config { - // Use base defaults to avoid depending on host state. - let codex_home = std::env::temp_dir(); - ConfigBuilder::default() - .codex_home(codex_home.clone()) - .build() - .await - .expect("config") -} - -fn snapshot(percent: f64) -> RateLimitSnapshot { - RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: percent, - window_minutes: Some(60), - resets_at: None, - }), - secondary: None, - credits: None, - plan_type: None, - } -} - -#[tokio::test] -async fn resumed_initial_messages_render_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - let conversation_id = ThreadId::new(); - let rollout_file = NamedTempFile::new().unwrap(); - let configured = codex_core::protocol::SessionConfiguredEvent { - session_id: conversation_id, - forked_from_id: None, - model: "test-model".to_string(), - model_provider_id: "test-provider".to_string(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, - initial_messages: Some(vec![ - EventMsg::UserMessage(UserMessageEvent { - message: "hello from user".to_string(), - images: None, - text_elements: Vec::new(), - local_images: Vec::new(), - }), - EventMsg::AgentMessage(AgentMessageEvent { - message: "assistant reply".to_string(), - }), - ]), - rollout_path: rollout_file.path().to_path_buf(), - }; - - chat.handle_codex_event(Event { - id: "initial".into(), - msg: EventMsg::SessionConfigured(configured), - }); - - let cells = drain_insert_history(&mut rx); - let mut merged_lines = Vec::new(); - for lines in cells { - let text = lines - .iter() - .flat_map(|line| line.spans.iter()) - .map(|span| span.content.clone()) - .collect::(); - merged_lines.push(text); - } - - let text_blob = merged_lines.join("\n"); - assert!( - text_blob.contains("hello from user"), - "expected replayed user message", - ); - assert!( - text_blob.contains("assistant reply"), - "expected replayed agent message", - ); -} - -/// Entering review mode uses the hint provided by the review request. -#[tokio::test] -async fn entered_review_mode_uses_request_hint() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - let banner = lines_to_single_string(cells.last().expect("review banner")); - assert_eq!(banner, ">> Code review started: feature branch <<\n"); - assert!(chat.is_review_mode); -} - -/// Entering review mode renders the current changes banner when requested. -#[tokio::test] -async fn entered_review_mode_defaults_to_current_changes_banner() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::UncommittedChanges, - user_facing_hint: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - let banner = lines_to_single_string(cells.last().expect("review banner")); - assert_eq!(banner, ">> Code review started: current changes <<\n"); - assert!(chat.is_review_mode); -} - -/// Exiting review restores the pre-review context window indicator. -#[tokio::test] -async fn review_restores_context_window_indicator() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; - - let context_window = 13_000; - let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. - let review_tokens = 12_030; // ~97% remaining after subtracting baseline. - - chat.handle_codex_event(Event { - id: "token-before".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(pre_review_tokens, context_window)), - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - - chat.handle_codex_event(Event { - id: "review-start".into(), - msg: EventMsg::EnteredReviewMode(ReviewRequest { - target: ReviewTarget::BaseBranch { - branch: "feature".to_string(), - }, - user_facing_hint: Some("feature branch".to_string()), - }), - }); - - chat.handle_codex_event(Event { - id: "token-review".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(review_tokens, context_window)), - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), Some(97)); - - chat.handle_codex_event(Event { - id: "review-end".into(), - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: None, - }), - }); - let _ = drain_insert_history(&mut rx); - - assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - assert!(!chat.is_review_mode); -} - -/// Receiving a TokenCount event without usage clears the context indicator. -#[tokio::test] -async fn token_count_none_resets_context_indicator() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(None).await; - - let context_window = 13_000; - let pre_compact_tokens = 12_700; - - chat.handle_codex_event(Event { - id: "token-before".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(make_token_info(pre_compact_tokens, context_window)), - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - - chat.handle_codex_event(Event { - id: "token-cleared".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: None, - rate_limits: None, - }), - }); - assert_eq!(chat.bottom_pane.context_window_percent(), None); -} - -#[tokio::test] -async fn context_indicator_shows_used_tokens_when_window_unknown() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await; - - chat.config.model_context_window = None; - let auto_compact_limit = 200_000; - chat.config.model_auto_compact_token_limit = Some(auto_compact_limit); - - // No model window, so the indicator should fall back to showing tokens used. - let total_tokens = 106_000; - let token_usage = TokenUsage { - total_tokens, - ..TokenUsage::default() - }; - let token_info = TokenUsageInfo { - total_token_usage: token_usage.clone(), - last_token_usage: token_usage, - model_context_window: None, - }; - - chat.handle_codex_event(Event { - id: "token-usage".into(), - msg: EventMsg::TokenCount(TokenCountEvent { - info: Some(token_info), - rate_limits: None, - }), - }); - - assert_eq!(chat.bottom_pane.context_window_percent(), None); - assert_eq!( - chat.bottom_pane.context_window_used_tokens(), - Some(total_tokens) - ); -} - -#[cfg_attr( - target_os = "macos", - ignore = "system configuration APIs are blocked under macOS seatbelt" -)] -#[tokio::test] -async fn helpers_are_available_and_do_not_panic() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let cfg = test_config().await; - let thread_manager = Arc::new(ThreadManager::with_models_provider( - CodexAuth::from_api_key("test"), - cfg.model_provider.clone(), - )); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let init = ChatWidgetInit { - config: cfg.clone(), - frame_requester: FrameRequester::test_dummy(), - app_event_tx: tx, - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: false, - auth_manager, - models_manager: thread_manager.get_models_manager(), - feedback: codex_feedback::CodexFeedback::new(), - is_first_run: true, - model: cfg.model, - }; - let mut w = ChatWidget::new(init, thread_manager); - // Basic construction sanity. - let _ = &mut w; -} - -// --- Helpers for tests that need direct construction and event draining --- -async fn make_chatwidget_manual( - model_override: Option<&str>, -) -> ( - ChatWidget, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, -) { - let (tx_raw, rx) = unbounded_channel::(); - let app_event_tx = AppEventSender::new(tx_raw); - let (op_tx, op_rx) = unbounded_channel::(); - let mut cfg = test_config().await; - let resolved_model = model_override - .map(str::to_owned) - .unwrap_or_else(|| ModelsManager::get_model_offline(cfg.model.as_deref())); - if let Some(model) = model_override { - cfg.model = Some(model.to_string()); - } - let mut bottom = BottomPane::new(BottomPaneParams { - app_event_tx: app_event_tx.clone(), - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: cfg.animations, - skills: None, - }); - bottom.set_steer_enabled(true); - bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let codex_home = cfg.codex_home.clone(); - let widget = ChatWidget { - app_event_tx, - codex_op_tx: op_tx, - bottom_pane: bottom, - active_cell: None, - active_cell_revision: 0, - config: cfg, - model: Some(resolved_model.clone()), - collaboration_mode: CollaborationModeSelection::default(), - auth_manager: auth_manager.clone(), - models_manager: Arc::new(ModelsManager::new(codex_home, auth_manager)), - session_header: SessionHeader::new(resolved_model), - initial_user_message: None, - token_info: None, - rate_limit_snapshot: None, - plan_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - rate_limit_poller: None, - stream_controller: None, - running_commands: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - last_unified_wait: None, - task_complete_pending: false, - agent_turn_running: false, - mcp_startup_status: None, - connectors_cache: ConnectorsCacheState::default(), - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, - conversation_id: None, - forked_from: None, - frame_requester: FrameRequester::test_dummy(), - show_welcome_banner: true, - queued_user_messages: VecDeque::new(), - suppress_session_configured_redraw: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - last_rendered_width: std::cell::Cell::new(None), - feedback: codex_feedback::CodexFeedback::new(), - current_rollout_path: None, - }; - (widget, rx, op_rx) -} - -// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper -// filters until we see a submission op. -fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Op { - loop { - match op_rx.try_recv() { - Ok(op @ Op::UserTurn { .. }) => return op, - Ok(_) => continue, - Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), - Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), - } - } -} - -fn set_chatgpt_auth(chat: &mut ChatWidget) { - chat.auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.models_manager = Arc::new(ModelsManager::new( - chat.config.codex_home.clone(), - chat.auth_manager.clone(), - )); -} - -pub(crate) async fn make_chatwidget_manual_with_sender() -> ( - ChatWidget, - AppEventSender, - tokio::sync::mpsc::UnboundedReceiver, - tokio::sync::mpsc::UnboundedReceiver, -) { - let (widget, rx, op_rx) = make_chatwidget_manual(None).await; - let app_event_tx = widget.app_event_tx.clone(); - (widget, app_event_tx, rx, op_rx) -} - -fn drain_insert_history( - rx: &mut tokio::sync::mpsc::UnboundedReceiver, -) -> Vec>> { - let mut out = Vec::new(); - while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = ev { - let mut lines = cell.display_lines(80); - if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { - lines.insert(0, "".into()); - } - out.push(lines) - } - } - out -} - -fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { - let mut s = String::new(); - for line in lines { - for span in &line.spans { - s.push_str(&span.content); - } - s.push('\n'); - } - s -} - -fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { - fn usage(total_tokens: i64) -> TokenUsage { - TokenUsage { - total_tokens, - ..TokenUsage::default() - } - } - - TokenUsageInfo { - total_token_usage: usage(total_tokens), - last_token_usage: usage(total_tokens), - model_context_window: Some(context_window), - } -} - -#[tokio::test] -async fn rate_limit_warnings_emit_thresholds() { - let mut state = RateLimitWarningState::default(); - let mut warnings: Vec = Vec::new(); - - warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299))); - warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299))); - warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299))); - warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299))); - warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299))); - - assert_eq!( - warnings, - vec![ - String::from( - "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." - ), - String::from( - "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", - ), - String::from( - "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." - ), - String::from( - "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", - ), - ], - "expected one warning per limit for the highest crossed threshold" - ); -} - -#[tokio::test] -async fn test_rate_limit_warnings_monthly() { - let mut state = RateLimitWarningState::default(); - let mut warnings: Vec = Vec::new(); - - warnings.extend(state.take_warnings(Some(75.0), Some(43199), None, None)); - assert_eq!( - warnings, - vec![String::from( - "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", - ),], - "expected one warning per limit for the highest crossed threshold" - ); -} - -#[tokio::test] -async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: None, - secondary: None, - credits: Some(CreditsSnapshot { - has_credits: true, - unlimited: false, - balance: Some("17.5".to_string()), - }), - plan_type: None, - })); - let initial_balance = chat - .rate_limit_snapshot - .as_ref() - .and_then(|snapshot| snapshot.credits.as_ref()) - .and_then(|credits| credits.balance.as_deref()); - assert_eq!(initial_balance, Some("17.5")); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 80.0, - window_minutes: Some(60), - resets_at: Some(123), - }), - secondary: None, - credits: None, - plan_type: None, - })); - - let display = chat - .rate_limit_snapshot - .as_ref() - .expect("rate limits should be cached"); - let credits = display - .credits - .as_ref() - .expect("credits should persist when headers omit them"); - - assert_eq!(credits.balance.as_deref(), Some("17.5")); - assert!(!credits.unlimited); - assert_eq!( - display.primary.as_ref().map(|window| window.used_percent), - Some(80.0) - ); -} - -#[tokio::test] -async fn rate_limit_snapshot_updates_and_retains_plan_type() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 10.0, - window_minutes: Some(60), - resets_at: None, - }), - secondary: Some(RateLimitWindow { - used_percent: 5.0, - window_minutes: Some(300), - resets_at: None, - }), - credits: None, - plan_type: Some(PlanType::Plus), - })); - assert_eq!(chat.plan_type, Some(PlanType::Plus)); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 25.0, - window_minutes: Some(30), - resets_at: Some(123), - }), - secondary: Some(RateLimitWindow { - used_percent: 15.0, - window_minutes: Some(300), - resets_at: Some(234), - }), - credits: None, - plan_type: Some(PlanType::Pro), - })); - assert_eq!(chat.plan_type, Some(PlanType::Pro)); - - chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 30.0, - window_minutes: Some(60), - resets_at: Some(456), - }), - secondary: Some(RateLimitWindow { - used_percent: 18.0, - window_minutes: Some(300), - resets_at: Some(567), - }), - credits: None, - plan_type: None, - })); - assert_eq!(chat.plan_type, Some(PlanType::Pro)); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await; - chat.auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - chat.on_rate_limit_snapshot(Some(snapshot(95.0))); - - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Idle - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_shows_once_per_session() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = AuthManager::from_auth_for_testing(auth); - - chat.on_rate_limit_snapshot(Some(snapshot(90.0))); - assert!( - chat.rate_limit_warnings.primary_index >= 1, - "warnings not emitted" - ); - chat.maybe_show_pending_rate_limit_prompt(); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - )); - - chat.on_rate_limit_snapshot(Some(snapshot(95.0))); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_respects_hidden_notice() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = AuthManager::from_auth_for_testing(auth); - chat.config.notices.hide_rate_limit_model_nudge = Some(true); - - chat.on_rate_limit_snapshot(Some(snapshot(95.0))); - - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Idle - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_defers_until_task_complete() { - let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = AuthManager::from_auth_for_testing(auth); - - chat.bottom_pane.set_task_running(true); - chat.on_rate_limit_snapshot(Some(snapshot(90.0))); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - )); - - chat.bottom_pane.set_task_running(false); - chat.maybe_show_pending_rate_limit_prompt(); - assert!(matches!( - chat.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - )); -} - -#[tokio::test] -async fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; - chat.auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - chat.on_rate_limit_snapshot(Some(snapshot(92.0))); - chat.maybe_show_pending_rate_limit_prompt(); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("rate_limit_switch_prompt_popup", popup); -} - -// (removed experimental resize snapshot test) - -#[tokio::test] -async fn exec_approval_emits_proposed_command_and_decision_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Trigger an exec approval request with a short, single-line command - let ev = ExecApprovalRequestEvent { - call_id: "call-short".into(), - turn_id: "turn-short".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-short".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let proposed_cells = drain_insert_history(&mut rx); - assert!( - proposed_cells.is_empty(), - "expected approval request to render via modal without emitting history cells" - ); - - // The approval modal should display the command snippet for user confirmation. - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}")); - - // Approve via keyboard and verify a concise decision history line is added - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - let decision = drain_insert_history(&mut rx) - .pop() - .expect("expected decision cell in history"); - assert_snapshot!( - "exec_approval_history_decision_approved_short", - lines_to_single_string(&decision) - ); -} - -#[tokio::test] -async fn exec_approval_decision_truncates_multiline_and_long_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Multiline command: modal should show full command, history records decision only - let ev_multi = ExecApprovalRequestEvent { - call_id: "call-multi".into(), - turn_id: "turn-multi".into(), - command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-multi".into(), - msg: EventMsg::ExecApprovalRequest(ev_multi), - }); - let proposed_multi = drain_insert_history(&mut rx); - assert!( - proposed_multi.is_empty(), - "expected multiline approval request to render via modal without emitting history cells" - ); - - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - let mut saw_first_line = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("echo line1") { - saw_first_line = true; - break; - } - } - assert!( - saw_first_line, - "expected modal to show first line of multiline snippet" - ); - - // Deny via keyboard; decision snippet should be single-line and elided with " ..." - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - let aborted_multi = drain_insert_history(&mut rx) - .pop() - .expect("expected aborted decision cell (multiline)"); - assert_snapshot!( - "exec_approval_history_decision_aborted_multiline", - lines_to_single_string(&aborted_multi) - ); - - // Very long single-line command: decision snippet should be truncated <= 80 chars with trailing ... - let long = format!("echo {}", "a".repeat(200)); - let ev_long = ExecApprovalRequestEvent { - call_id: "call-long".into(), - turn_id: "turn-long".into(), - command: vec!["bash".into(), "-lc".into(), long], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - proposed_execpolicy_amendment: None, - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-long".into(), - msg: EventMsg::ExecApprovalRequest(ev_long), - }); - let proposed_long = drain_insert_history(&mut rx); - assert!( - proposed_long.is_empty(), - "expected long approval request to avoid emitting history cells before decision" - ); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - let aborted_long = drain_insert_history(&mut rx) - .pop() - .expect("expected aborted decision cell (long)"); - assert_snapshot!( - "exec_approval_history_decision_aborted_long", - lines_to_single_string(&aborted_long) - ); -} - -// --- Small helpers to tersely drive exec begin/end and snapshot active cell --- -fn begin_exec_with_source( - chat: &mut ChatWidget, - call_id: &str, - raw_cmd: &str, - source: ExecCommandSource, -) -> ExecCommandBeginEvent { - // Build the full command vec and parse it using core's parser, - // then convert to protocol variants for the event payload. - let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; - let parsed_cmd: Vec = codex_core::parse_command::parse_command(&command); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let interaction_input = None; - let event = ExecCommandBeginEvent { - call_id: call_id.to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source, - interaction_input, - }; - chat.handle_codex_event(Event { - id: call_id.to_string(), - msg: EventMsg::ExecCommandBegin(event.clone()), - }); - event -} - -fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent { - begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent) -} - -fn end_exec( - chat: &mut ChatWidget, - begin_event: ExecCommandBeginEvent, - stdout: &str, - stderr: &str, - exit_code: i32, -) { - let aggregated = if stderr.is_empty() { - stdout.to_string() - } else { - format!("{stdout}{stderr}") - }; - let ExecCommandBeginEvent { - call_id, - turn_id, - command, - cwd, - parsed_cmd, - source, - interaction_input, - process_id, - } = begin_event; - chat.handle_codex_event(Event { - id: call_id.clone(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, - process_id, - turn_id, - command, - cwd, - parsed_cmd, - source, - interaction_input, - stdout: stdout.to_string(), - stderr: stderr.to_string(), - aggregated_output: aggregated.clone(), - exit_code, - duration: std::time::Duration::from_millis(5), - formatted_output: aggregated, - }), - }); -} - -fn active_blob(chat: &ChatWidget) -> String { - let lines = chat - .active_cell - .as_ref() - .expect("active cell present") - .display_lines(80); - lines_to_single_string(&lines) -} - -fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { - let models = chat - .models_manager - .try_list_models(&chat.config) - .expect("models lock available"); - models - .iter() - .find(|&preset| preset.model == model) - .cloned() - .unwrap_or_else(|| panic!("{model} preset not found")) -} - -#[tokio::test] -async fn empty_enter_during_task_does_not_queue() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate running task so submissions would normally be queued. - chat.bottom_pane.set_task_running(true); - - // Press Enter with an empty composer. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Ensure nothing was queued. - assert!(chat.queued_user_messages.is_empty()); -} - -#[tokio::test] -async fn alt_up_edits_most_recent_queued_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate a running task so messages would normally be queued. - chat.bottom_pane.set_task_running(true); - - // Seed two queued messages. - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); - - // Press Alt+Up to edit the most recent (last) queued message. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); - - // Composer should now contain the last queued message. - assert_eq!( - chat.bottom_pane.composer_text(), - "second queued".to_string() - ); - // And the queue should now contain only the remaining (older) item. - assert_eq!(chat.queued_user_messages.len(), 1); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "first queued" - ); -} - -/// Pressing Up to recall the most recent history entry and immediately queuing -/// it while a task is running should always enqueue the same text, even when it -/// is queued repeatedly. -#[tokio::test] -async fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - assert!(!chat.bottom_pane.is_task_running()); - - // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Simulate an active task so further submissions are queued. - chat.bottom_pane.set_task_running(true); - assert!(chat.bottom_pane.is_task_running()); - - for _ in 0..3 { - // Recall the prompt from history and ensure it is what we expect. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); - - // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - } - - assert_eq!(chat.queued_user_messages.len(), 3); - for message in chat.queued_user_messages.iter() { - assert_eq!(message.text, "repeat me"); - } -} - -#[tokio::test] -async fn streaming_final_answer_keeps_task_running_state() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - - chat.on_task_started(); - assert!(chat.bottom_pane.is_task_running()); - chat.on_agent_message_delta("Final answer line\n".to_string()); - chat.on_commit_tick(); - - assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_widget().is_none()); - - chat.bottom_pane - .set_composer_text("queued submission".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(chat.queued_user_messages.len(), 1); - assert_eq!( - chat.queued_user_messages.front().unwrap().text, - "queued submission" - ); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - match op_rx.try_recv() { - Ok(Op::Interrupt) => {} - other => panic!("expected Op::Interrupt, got {other:?}"), - } - assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); -} - -#[tokio::test] -async fn ctrl_c_shutdown_works_with_caps_lock() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); - - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn ctrl_d_quits_without_prompt() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn ctrl_d_with_modal_open_does_not_quit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.open_approvals_popup(); - chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - - assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); -} - -#[tokio::test] -async fn ctrl_c_cleared_prompt_is_recoverable_via_history() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - - chat.bottom_pane.insert_str("draft message "); - chat.bottom_pane - .attach_image(PathBuf::from("/tmp/preview.png")); - let placeholder = "[Image #1]"; - assert!( - chat.bottom_pane.composer_text().ends_with(placeholder), - "expected placeholder {placeholder:?} in composer text" - ); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); - assert!(chat.bottom_pane.composer_text().is_empty()); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); - - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - let restored_text = chat.bottom_pane.composer_text(); - assert!( - restored_text.ends_with(placeholder), - "expected placeholder {placeholder:?} after history recall" - ); - assert!(restored_text.starts_with("draft message ")); - assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); - - let images = chat.bottom_pane.take_recent_submission_images(); - assert!( - images.is_empty(), - "attachments are not preserved in history recall" - ); -} - -#[tokio::test] -async fn exec_history_cell_shows_working_then_completed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin command - let begin = begin_exec(&mut chat, "call-1", "echo done"); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); - - // End command successfully - end_exec(&mut chat, begin, "done", "", 0); - - let cells = drain_insert_history(&mut rx); - // Exec end now finalizes and flushes the exec cell immediately. - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - // Inspect the flushed exec cell rendering. - let lines = &cells[0]; - let blob = lines_to_single_string(lines); - // New behavior: no glyph markers; ensure command is shown and no panic. - assert!( - blob.contains("• Ran"), - "expected summary header present: {blob:?}" - ); - assert!( - blob.contains("echo done"), - "expected command text to be present: {blob:?}" - ); -} - -#[tokio::test] -async fn exec_history_cell_shows_working_then_failed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin command - let begin = begin_exec(&mut chat, "call-2", "false"); - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet"); - - // End command with failure - end_exec(&mut chat, begin, "", "Bloop", 2); - - let cells = drain_insert_history(&mut rx); - // Exec end with failure should also flush immediately. - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let lines = &cells[0]; - let blob = lines_to_single_string(lines); - assert!( - blob.contains("• Ran false"), - "expected command and header text present: {blob:?}" - ); - assert!(blob.to_lowercase().contains("bloop"), "expected error text"); -} - -#[tokio::test] -async fn exec_end_without_begin_uses_event_command() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "echo orphaned".to_string(), - ]; - let parsed_cmd = codex_core::parse_command::parse_command(&command); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - chat.handle_codex_event(Event { - id: "call-orphan".to_string(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "call-orphan".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: "done".to_string(), - stderr: String::new(), - aggregated_output: "done".to_string(), - exit_code: 0, - duration: std::time::Duration::from_millis(5), - formatted_output: "done".to_string(), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let blob = lines_to_single_string(&cells[0]); - assert!( - blob.contains("• Ran echo orphaned"), - "expected command text to come from event: {blob:?}" - ); - assert!( - !blob.contains("call-orphan"), - "call id should not be rendered when event has the command: {blob:?}" - ); -} - -#[tokio::test] -async fn exec_history_shows_unified_exec_startup_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let begin = begin_exec_with_source( - &mut chat, - "call-startup", - "echo unified exec startup", - ExecCommandSource::UnifiedExecStartup, - ); - assert!( - drain_insert_history(&mut rx).is_empty(), - "exec begin should not flush until completion" - ); - - end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); - let blob = lines_to_single_string(&cells[0]); - assert!( - blob.contains("• Ran echo unified exec startup"), - "expected startup command to render: {blob:?}" - ); -} - -/// Selecting the custom prompt option from the review popup sends -/// OpenReviewCustomPrompt to the app event channel. -#[tokio::test] -async fn review_popup_custom_prompt_action_sends_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the preset selection popup - chat.open_review_popup(); - - // Move selection down to the fourth item: "Custom review instructions" - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - // Activate - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Drain events and ensure we saw the OpenReviewCustomPrompt request - let mut found = false; - while let Ok(ev) = rx.try_recv() { - if let AppEvent::OpenReviewCustomPrompt = ev { - found = true; - break; - } - } - assert!(found, "expected OpenReviewCustomPrompt event to be sent"); -} - -#[tokio::test] -async fn slash_init_skips_when_project_doc_exists() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - let tempdir = tempdir().unwrap(); - let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); - std::fs::write(&existing_path, "existing instructions").unwrap(); - chat.config.cwd = tempdir.path().to_path_buf(); - - chat.dispatch_command(SlashCommand::Init); - - match op_rx.try_recv() { - Err(TryRecvError::Empty) => {} - other => panic!("expected no Codex op to be sent, got {other:?}"), - } - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one info message"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains(DEFAULT_PROJECT_DOC_FILENAME), - "info message should mention the existing file: {rendered:?}" - ); - assert!( - rendered.contains("Skipping /init"), - "info message should explain why /init was skipped: {rendered:?}" - ); - assert_eq!( - std::fs::read_to_string(existing_path).unwrap(), - "existing instructions" - ); -} - -#[test] -fn parse_collaboration_mode_selection_accepts_common_aliases() { - assert_eq!( - collaboration_modes::parse_selection("plan"), - Some(CollaborationModeSelection::Plan) - ); - assert_eq!( - collaboration_modes::parse_selection("PAIR"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pair_programming"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection("pp"), - Some(CollaborationModeSelection::PairProgramming) - ); - assert_eq!( - collaboration_modes::parse_selection(" exec "), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!( - collaboration_modes::parse_selection("execute"), - Some(CollaborationModeSelection::Execute) - ); - assert_eq!(collaboration_modes::parse_selection("unknown"), None); -} - -#[tokio::test] -async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.set_feature_enabled(Feature::CollaborationModes, false); - - let initial = chat.collaboration_mode; - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, initial); - - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Execute); - - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); - - chat.on_task_started(); - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); -} - -#[tokio::test] -async fn collab_slash_command_sets_mode_and_next_submit_sends_user_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.dispatch_command_with_args(SlashCommand::Collab, "plan".to_string()); - assert_eq!(chat.collaboration_mode, CollaborationModeSelection::Plan); - - chat.bottom_pane.set_composer_text("hello".to_string()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), - .. - } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), - } - - chat.bottom_pane.set_composer_text("follow up".to_string()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Plan(_)), - .. - } => {} - other => panic!("expected Op::UserTurn with plan collab mode, got {other:?}"), - } -} - -#[tokio::test] -async fn collab_mode_defaults_to_pair_programming_when_enabled() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.bottom_pane.set_composer_text("hello".to_string()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), - .. - } => {} - other => panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}"), - } -} - -#[tokio::test] -async fn slash_quit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Quit); - - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn slash_exit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Exit); - - assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); -} - -#[tokio::test] -async fn slash_resume_opens_picker() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Resume); - - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); -} - -#[tokio::test] -async fn slash_fork_requests_current_fork() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Fork); - - assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession)); -} - -#[tokio::test] -async fn slash_rollout_displays_current_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); - chat.current_rollout_path = Some(rollout_path.clone()); - - chat.dispatch_command(SlashCommand::Rollout); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected info message for rollout path"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains(&rollout_path.display().to_string()), - "expected rollout path to be shown: {rendered}" - ); -} - -#[tokio::test] -async fn slash_rollout_handles_missing_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.dispatch_command(SlashCommand::Rollout); - - let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected info message explaining missing path" - ); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("not available"), - "expected missing rollout path message: {rendered}" - ); -} - -#[tokio::test] -async fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "turn-1".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { - message: Some("Undo requested for the last turn...".to_string()), - }), - }); - assert!( - chat.bottom_pane.status_indicator_visible(), - "status indicator should be visible during undo" - ); - - chat.handle_codex_event(Event { - id: "turn-1".to_string(), - msg: EventMsg::UndoCompleted(UndoCompletedEvent { - success: true, - message: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected final status only"); - assert!( - !chat.bottom_pane.status_indicator_visible(), - "status indicator should be hidden after successful undo" - ); - - let completed = lines_to_single_string(&cells[0]); - assert!( - completed.contains("Undo completed successfully."), - "expected default success message, got {completed:?}" - ); -} - -#[tokio::test] -async fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "turn-2".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), - }); - assert!( - chat.bottom_pane.status_indicator_visible(), - "status indicator should be visible during undo" - ); - - chat.handle_codex_event(Event { - id: "turn-2".to_string(), - msg: EventMsg::UndoCompleted(UndoCompletedEvent { - success: false, - message: Some("Failed to restore workspace state.".to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected final status only"); - assert!( - !chat.bottom_pane.status_indicator_visible(), - "status indicator should be hidden after failed undo" - ); - - let completed = lines_to_single_string(&cells[0]); - assert!( - completed.contains("Failed to restore workspace state."), - "expected failure message, got {completed:?}" - ); -} - -#[tokio::test] -async fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "turn-hint".to_string(), - msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }), - }); - - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be active"); - assert!( - !status.interrupt_hint_visible(), - "undo should hide the interrupt hint because the operation cannot be cancelled" - ); -} - -/// The commit picker shows only commit subjects (no timestamps). -#[tokio::test] -async fn review_commit_picker_shows_subjects_without_timestamps() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the Review presets parent popup. - chat.open_review_popup(); - - // Show commit picker with synthetic entries. - let entries = vec![ - codex_core::git_info::CommitLogEntry { - sha: "1111111deadbeef".to_string(), - timestamp: 0, - subject: "Add new feature X".to_string(), - }, - codex_core::git_info::CommitLogEntry { - sha: "2222222cafebabe".to_string(), - timestamp: 0, - subject: "Fix bug Y".to_string(), - }, - ]; - super::show_review_commit_picker_with_entries(&mut chat, entries); - - // Render the bottom pane and inspect the lines for subjects and absence of time words. - let width = 72; - let height = chat.desired_height(width); - let area = ratatui::layout::Rect::new(0, 0, width, height); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - - let mut blob = String::new(); - for y in 0..area.height { - for x in 0..area.width { - let s = buf[(x, y)].symbol(); - if s.is_empty() { - blob.push(' '); - } else { - blob.push_str(s); - } - } - blob.push('\n'); - } - - assert!( - blob.contains("Add new feature X"), - "expected subject in output" - ); - assert!(blob.contains("Fix bug Y"), "expected subject in output"); - - // Ensure no relative-time phrasing is present. - let lowered = blob.to_lowercase(); - assert!( - !lowered.contains("ago") - && !lowered.contains(" second") - && !lowered.contains(" minute") - && !lowered.contains(" hour") - && !lowered.contains(" day"), - "expected no relative time in commit picker output: {blob:?}" - ); -} - -/// Submitting the custom prompt view sends Op::Review with the typed prompt -/// and uses the same text for the user-facing hint. -#[tokio::test] -async fn custom_prompt_submit_sends_review_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.show_review_custom_prompt(); - // Paste prompt text via ChatWidget handler, then submit - chat.handle_paste(" please audit dependencies ".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt - let evt = rx.try_recv().expect("expected one app event"); - match evt { - AppEvent::CodexOp(Op::Review { review_request }) => { - assert_eq!( - review_request, - ReviewRequest { - target: ReviewTarget::Custom { - instructions: "please audit dependencies".to_string(), - }, - user_facing_hint: None, - } - ); - } - other => panic!("unexpected app event: {other:?}"), - } -} - -/// Hitting Enter on an empty custom prompt view does not submit. -#[tokio::test] -async fn custom_prompt_enter_empty_does_not_send() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.show_review_custom_prompt(); - // Enter without any text - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // No AppEvent::CodexOp should be sent - assert!(rx.try_recv().is_err(), "no app event should be sent"); -} - -#[tokio::test] -async fn view_image_tool_call_adds_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let image_path = chat.config.cwd.join("example.png"); - - chat.handle_codex_event(Event { - id: "sub-image".into(), - msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { - call_id: "call-image".into(), - path: image_path, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected a single history cell"); - let combined = lines_to_single_string(&cells[0]); - assert_snapshot!("local_image_attachment_history_snapshot", combined); -} - -// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗ -// marker (replacing the spinner) and flushes it into history. -#[tokio::test] -async fn interrupt_exec_marks_failed_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin a long-running command so we have an active exec cell with a spinner. - begin_exec(&mut chat, "call-int", "sleep 1"); - - // Simulate the task being aborted (as if ESC was pressed), which should - // cause the active exec cell to be finalized as failed and flushed. - chat.handle_codex_event(Event { - id: "call-int".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected finalized exec cell to be inserted into history" - ); - - // The first inserted cell should be the finalized exec; snapshot its text. - let exec_blob = lines_to_single_string(&cells[0]); - assert_snapshot!("interrupt_exec_marks_failed", exec_blob); -} - -// Snapshot test: after an interrupted turn, a gentle error message is inserted -// suggesting the user to tell the model what to do differently and to use /feedback. -#[tokio::test] -async fn interrupted_turn_error_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate an in-progress task so the widget is in a running state. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - - // Abort the turn (like pressing Esc) and drain inserted history. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected error message to be inserted after interruption" - ); - let last = lines_to_single_string(cells.last().unwrap()); - assert_snapshot!("interrupted_turn_error_message", last); -} - -/// Opening custom prompt from the review popup, pressing Esc returns to the -/// parent popup, pressing Esc again dismisses all panels (back to normal mode). -#[tokio::test] -async fn review_custom_prompt_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the Review presets parent popup. - chat.open_review_popup(); - - // Open the custom prompt submenu (child view) directly. - chat.show_review_custom_prompt(); - - // Verify child view is on top. - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Custom review instructions"), - "expected custom prompt view header: {header:?}" - ); - - // Esc once: child view closes, parent (review presets) remains. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Select a review preset"), - "expected to return to parent review popup: {header:?}" - ); - - // Esc again: parent closes; back to normal composer state. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!( - chat.is_normal_backtrack_mode(), - "expected to be back in normal composer mode" - ); -} - -/// Opening base-branch picker from the review popup, pressing Esc returns to the -/// parent popup, pressing Esc again dismisses all panels (back to normal mode). -#[tokio::test] -async fn review_branch_picker_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the Review presets parent popup. - chat.open_review_popup(); - - // Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine. - let cwd = std::env::temp_dir(); - chat.show_review_branch_picker(&cwd).await; - - // Verify child view header. - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Select a base branch"), - "expected branch picker header: {header:?}" - ); - - // Esc once: child view closes, parent remains. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let header = render_bottom_first_row(&chat, 60); - assert!( - header.contains("Select a review preset"), - "expected to return to parent review popup: {header:?}" - ); - - // Esc again: parent closes; back to normal composer state. - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!( - chat.is_normal_backtrack_mode(), - "expected to be back in normal composer mode" - ); -} - -fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { - let height = chat.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - let s = buf[(x, y)].symbol(); - if s.is_empty() { - row.push(' '); - } else { - row.push_str(s); - } - } - if !row.trim().is_empty() { - return row; - } - } - String::new() -} - -fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { - let height = chat.desired_height(width); - let area = Rect::new(0, 0, width, height); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - - let mut lines: Vec = (0..area.height) - .map(|row| { - let mut line = String::new(); - for col in 0..area.width { - let symbol = buf[(area.x + col, area.y + row)].symbol(); - if symbol.is_empty() { - line.push(' '); - } else { - line.push_str(symbol); - } - } - line.trim_end().to_string() - }) - .collect(); - - while lines.first().is_some_and(|line| line.trim().is_empty()) { - lines.remove(0); - } - while lines.last().is_some_and(|line| line.trim().is_empty()) { - lines.pop(); - } - - lines.join("\n") -} - -#[tokio::test] -async fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).await; - chat.conversation_id = Some(ThreadId::new()); - chat.open_model_popup(); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_selection_popup", popup); -} - -#[tokio::test] -async fn model_picker_hides_show_in_picker_false_models_from_cache() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await; - let preset = |slug: &str, show_in_picker: bool| ModelPreset { - id: slug.to_string(), - model: slug.to_string(), - display_name: slug.to_string(), - description: format!("{slug} description"), - default_reasoning_effort: ReasoningEffortConfig::Medium, - supported_reasoning_efforts: vec![ReasoningEffortPreset { - effort: ReasoningEffortConfig::Medium, - description: "medium".to_string(), - }], - is_default: false, - upgrade: None, - show_in_picker, - supported_in_api: true, - }; - - chat.open_model_popup_with_presets(vec![ - preset("test-visible-model", true), - preset("test-hidden-model", false), - ]); - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_picker_filters_hidden_models", popup); - assert!( - popup.contains("test-visible-model"), - "expected visible model to appear in picker:\n{popup}" - ); - assert!( - !popup.contains("test-hidden-model"), - "expected hidden model to be excluded from picker:\n{popup}" - ); -} - -#[tokio::test] -async fn approvals_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.config.notices.hide_full_access_warning = None; - chat.open_approvals_popup(); - - let popup = render_bottom_popup(&chat, 80); - #[cfg(target_os = "windows")] - insta::with_settings!({ snapshot_suffix => "windows" }, { - assert_snapshot!("approvals_selection_popup", popup); - }); - #[cfg(not(target_os = "windows"))] - assert_snapshot!("approvals_selection_popup", popup); -} - -#[cfg(target_os = "windows")] -#[tokio::test] -#[serial] -async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some(); - let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled(); - - chat.config.notices.hide_full_access_warning = None; - chat.config.features.enable(Feature::WindowsSandbox); - chat.config - .features - .disable(Feature::WindowsSandboxElevated); - set_windows_sandbox_enabled(true); - set_windows_elevated_sandbox_enabled(false); - - chat.open_approvals_popup(); - - let popup = render_bottom_popup(&chat, 80); - insta::with_settings!({ snapshot_suffix => "windows_degraded" }, { - assert_snapshot!("approvals_selection_popup", popup); - }); - - // Avoid leaking sandbox global state into other tests. - set_windows_sandbox_enabled(was_sandbox_enabled); - set_windows_elevated_sandbox_enabled(was_elevated_enabled); -} - -#[tokio::test] -async fn preset_matching_ignores_extra_writable_roots() { - let preset = builtin_approval_presets() - .into_iter() - .find(|p| p.id == "auto") - .expect("auto preset exists"); - let current_sandbox = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - assert!( - ChatWidget::preset_matches_current(AskForApproval::OnRequest, ¤t_sandbox, &preset), - "WorkspaceWrite with extra roots should still match the Agent preset" - ); - assert!( - !ChatWidget::preset_matches_current(AskForApproval::Never, ¤t_sandbox, &preset), - "approval mismatch should prevent matching the preset" - ); -} - -#[tokio::test] -async fn full_access_confirmation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let preset = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "full-access") - .expect("full access preset"); - chat.open_full_access_confirmation(preset); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("full_access_confirmation_popup", popup); -} - -#[cfg(target_os = "windows")] -#[tokio::test] -async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - let preset = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - .expect("auto preset"); - chat.open_windows_sandbox_enable_prompt(preset); - - let popup = render_bottom_popup(&chat, 120); - assert!( - popup.contains("requires elevation"), - "expected auto mode prompt to mention elevation, popup: {popup}" - ); -} - -#[cfg(target_os = "windows")] -#[tokio::test] -async fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - set_windows_sandbox_enabled(false); - chat.config.forced_auto_mode_downgraded_on_windows = true; - - chat.maybe_prompt_windows_sandbox_enable(); - - let popup = render_bottom_popup(&chat, 120); - assert!( - popup.contains("requires elevation"), - "expected startup prompt to explain elevation: {popup}" - ); - assert!( - popup.contains("Set up agent sandbox"), - "expected startup prompt to offer agent sandbox setup: {popup}" - ); - assert!( - popup.contains("Stay in"), - "expected startup prompt to offer staying in current mode: {popup}" - ); - - set_windows_sandbox_enabled(true); -} - -#[tokio::test] -async fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - - set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_reasoning_selection_popup", popup); -} - -#[tokio::test] -async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - - set_chatgpt_auth(&mut chat); - chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("model_reasoning_selection_popup_extra_high_warning", popup); -} - -#[tokio::test] -async fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - - set_chatgpt_auth(&mut chat); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 120); - assert!( - popup.contains("Extra high"), - "expected popup to include 'Extra high'; popup: {popup}" - ); - assert!( - !popup.contains("Extrahigh"), - "expected popup not to include 'Extrahigh'; popup: {popup}" - ); -} - -#[tokio::test] -async fn single_reasoning_option_skips_selection() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let single_effort = vec![ReasoningEffortPreset { - effort: ReasoningEffortConfig::High, - description: "Greater reasoning depth for complex or ambiguous problems".to_string(), - }]; - let preset = ModelPreset { - id: "model-with-single-reasoning".to_string(), - model: "model-with-single-reasoning".to_string(), - display_name: "model-with-single-reasoning".to_string(), - description: "".to_string(), - default_reasoning_effort: ReasoningEffortConfig::High, - supported_reasoning_efforts: single_effort, - is_default: false, - upgrade: None, - show_in_picker: true, - supported_in_api: true, - }; - chat.open_reasoning_popup(preset); - - let popup = render_bottom_popup(&chat, 80); - assert!( - !popup.contains("Select Reasoning Level"), - "expected reasoning selection popup to be skipped" - ); - - let mut events = Vec::new(); - while let Ok(ev) = rx.try_recv() { - events.push(ev); - } - - assert!( - events - .iter() - .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), - "expected reasoning effort to be applied automatically; events: {events:?}" - ); -} - -#[tokio::test] -async fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the feedback category selection popup via slash command. - chat.dispatch_command(SlashCommand::Feedback); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("feedback_selection_popup", popup); -} - -#[tokio::test] -async fn feedback_upload_consent_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // Open the consent popup directly for a chosen category. - chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); - - let popup = render_bottom_popup(&chat, 80); - assert_snapshot!("feedback_upload_consent_popup", popup); -} - -#[tokio::test] -async fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await; - chat.conversation_id = Some(ThreadId::new()); - chat.open_model_popup(); - - let preset = get_available_model(&chat, "gpt-5.1-codex-max"); - chat.open_reasoning_popup(preset); - - let before_escape = render_bottom_popup(&chat, 80); - assert!(before_escape.contains("Select Reasoning Level")); - - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - - let after_escape = render_bottom_popup(&chat, 80); - assert!(after_escape.contains("Select Model")); - assert!(!after_escape.contains("Select Reasoning Level")); -} - -#[tokio::test] -async fn exec_history_extends_previous_when_consecutive() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - // 1) Start "ls -la" (List) - let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); - assert_snapshot!("exploring_step1_start_ls", active_blob(&chat)); - - // 2) Finish "ls -la" - end_exec(&mut chat, begin_ls, "", "", 0); - assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat)); - - // 3) Start "cat foo.txt" (Read) - let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt"); - assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat)); - - // 4) Complete "cat foo.txt" - end_exec(&mut chat, begin_cat_foo, "hello from foo", "", 0); - assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat)); - - // 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt) - let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt"); - end_exec(&mut chat, begin_sed_range, "chunk", "", 0); - assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat)); - - // 6) Start & complete "cat bar.txt" - let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt"); - end_exec(&mut chat, begin_cat_bar, "hello from bar", "", 0); - assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat)); -} - -#[tokio::test] -async fn user_shell_command_renders_output_not_exploring() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let begin_ls = begin_exec_with_source( - &mut chat, - "user-shell-ls", - "ls", - ExecCommandSource::UserShell, - ); - end_exec(&mut chat, begin_ls, "file1\nfile2\n", "", 0); - - let cells = drain_insert_history(&mut rx); - assert_eq!( - cells.len(), - 1, - "expected a single history cell for the user command" - ); - let blob = lines_to_single_string(cells.first().unwrap()); - assert_snapshot!("user_shell_ls_output", blob); -} - -#[tokio::test] -async fn disabled_slash_command_while_task_running_snapshot() { - // Build a chat widget and simulate an active task - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); - - // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); - - // Drain history and snapshot the rendered error line(s) - let cells = drain_insert_history(&mut rx); - assert!( - !cells.is_empty(), - "expected an error message history cell to be emitted", - ); - let blob = lines_to_single_string(cells.last().unwrap()); - assert_snapshot!(blob); -} - -// -// Snapshot test: command approval modal -// -// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal -// and snapshots the visual output using the ratatui TestBackend. -#[tokio::test] -async fn approval_modal_exec_snapshot() { - // Build a chat widget with manual channels to avoid spawning the agent. - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - // Inject an exec approval request to display the approval modal. - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd".into(), - turn_id: "turn-approve-cmd".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - // Render to a fixed-size test terminal and snapshot. - // Call desired_height first and use that exact height for rendering. - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) - .expect("create terminal"); - let viewport = Rect::new(0, 0, width, height); - terminal.set_viewport_area(viewport); - - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal"); - assert!( - terminal - .backend() - .vt100() - .screen() - .contents() - .contains("echo hello world") - ); - assert_snapshot!( - "approval_modal_exec", - terminal.backend().vt100().screen().contents() - ); -} - -// Snapshot test: command approval modal without a reason -// Ensures spacing looks correct when no reason text is provided. -#[tokio::test] -async fn approval_modal_exec_without_reason_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd-noreason".into(), - turn_id: "turn-approve-cmd-noreason".into(), - command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello".into(), - "world".into(), - ])), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-noreason".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal (no reason)"); - assert_snapshot!( - "approval_modal_exec_no_reason", - terminal.backend().vt100().screen().contents() - ); -} - -// Snapshot test: approval modal with a proposed execpolicy prefix that is multi-line; -// we should not offer adding it to execpolicy. -#[tokio::test] -async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - let script = "python - <<'PY'\nprint('hello')\nPY".to_string(); - let command = vec!["bash".into(), "-lc".into(), script]; - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-cmd-multiline-trunc".into(), - turn_id: "turn-approve-cmd-multiline-trunc".into(), - command: command.clone(), - cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-multiline-trunc".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - let width = 100; - let height = chat.desired_height(width); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw approval modal (multiline prefix)"); - let contents = terminal.backend().vt100().screen().contents(); - assert!(!contents.contains("don't ask again")); - assert_snapshot!( - "approval_modal_exec_multiline_prefix_no_execpolicy", - contents - ); -} - -// Snapshot test: patch approval modal -#[tokio::test] -async fn approval_modal_patch_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - // Build a small changeset and a reason/grant_root to exercise the prompt text. - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("README.md"), - FileChange::Add { - content: "hello\nworld\n".into(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "call-approve-patch".into(), - turn_id: "turn-approve-patch".into(), - changes, - reason: Some("The model wants to apply changes".into()), - grant_root: Some(PathBuf::from("/tmp")), - }; - chat.handle_codex_event(Event { - id: "sub-approve-patch".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); - - // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = - ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, 80, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw patch approval modal"); - assert_snapshot!( - "approval_modal_patch", - terminal.backend().vt100().screen().contents() - ); -} - -#[tokio::test] -async fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - // Simulate a running task to enable queuing of user inputs. - chat.bottom_pane.set_task_running(true); - - // Queue two user messages while the task is running. - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); - - // Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed). - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - // Composer should now contain the queued messages joined by newlines, in order. - assert_eq!( - chat.bottom_pane.composer_text(), - "first queued\nsecond queued" - ); - - // Queue should be cleared and no new user input should have been auto-submitted. - assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); - - // Drain rx to avoid unused warnings. - let _ = drain_insert_history(&mut rx); -} - -#[tokio::test] -async fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - chat.bottom_pane.set_task_running(true); - chat.bottom_pane - .set_composer_text("current draft".to_string()); - - chat.queued_user_messages - .push_back(UserMessage::from("first queued".to_string())); - chat.queued_user_messages - .push_back(UserMessage::from("second queued".to_string())); - chat.refresh_queued_user_messages(); - - chat.handle_codex_event(Event { - id: "turn-1".into(), - msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { - reason: TurnAbortReason::Interrupted, - }), - }); - - assert_eq!( - chat.bottom_pane.composer_text(), - "first queued\nsecond queued\ncurrent draft" - ); - assert!(chat.queued_user_messages.is_empty()); - assert!( - op_rx.try_recv().is_err(), - "unexpected outbound op after interrupt" - ); - - let _ = drain_insert_history(&mut rx); -} - -// Snapshot test: ChatWidget at very small heights (idle) -// Ensures overall layout behaves when terminal height is extremely constrained. -#[tokio::test] -async fn ui_snapshots_small_heights_idle() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - for h in [1u16, 2, 3] { - let name = format!("chat_small_idle_h{h}"); - let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat idle"); - assert_snapshot!(name, terminal.backend()); - } -} - -// Snapshot test: ChatWidget at very small heights (task running) -// Validates how status + composer are presented within tight space. -#[tokio::test] -async fn ui_snapshots_small_heights_task_running() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Activate status line - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Thinking**".into(), - }), - }); - for h in [1u16, 2, 3] { - let name = format!("chat_small_running_h{h}"); - let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat running"); - assert_snapshot!(name, terminal.backend()); - } -} - -// Snapshot test: status widget + approval modal active together -// The modal takes precedence visually; this captures the layout with a running -// task (status indicator active) while an approval request is shown. -#[tokio::test] -async fn status_widget_and_approval_modal_snapshot() { - use codex_core::protocol::ExecApprovalRequestEvent; - - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Begin a running task so the status indicator would be active. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header for the status line. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - - // Now show an approval modal (e.g. exec approval). - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-exec".into(), - turn_id: "turn-approve-exec".into(), - command: vec!["echo".into(), "hello world".into()], - cwd: PathBuf::from("/tmp"), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".into(), - "hello world".into(), - ])), - parsed_cmd: vec![], - }; - chat.handle_codex_event(Event { - id: "sub-approve-exec".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - // Render at the widget's desired height and snapshot. - let width: u16 = 100; - let height = chat.desired_height(width); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) - .expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw status + approval modal"); - assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); -} - -// Snapshot test: status widget active (StatusIndicatorView) -// Ensures the VT100 rendering of the status indicator is stable when active. -#[tokio::test] -async fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Activate the status indicator by simulating a task start. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header via a bold reasoning chunk. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - // Render and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw status widget"); - assert_snapshot!("status_widget_active", terminal.backend()); -} - -#[tokio::test] -async fn mcp_startup_header_booting_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.show_welcome_banner = false; - - chat.handle_codex_event(Event { - id: "mcp-1".into(), - msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent { - server: "alpha".into(), - status: McpStartupStatus::Starting, - }), - }); - - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| chat.render(f.area(), f.buffer_mut())) - .expect("draw chat widget"); - assert_snapshot!("mcp_startup_header_booting", terminal.backend()); -} - -#[tokio::test] -async fn mcp_startup_complete_does_not_clear_running_task() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - - // The bottom pane has a single "task running" indicator even though MCP startup and an agent - // turn are tracked independently, so a startup completion event must not clear an active turn. - assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_indicator_visible()); - - chat.handle_codex_event(Event { - id: "mcp-1".into(), - msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent { - ready: vec!["schaltwerk".into()], - ..Default::default() - }), - }); - - assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_indicator_visible()); -} - -#[tokio::test] -async fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - chat.handle_codex_event(Event { - id: "bg-1".into(), - msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: "Waiting for `vim`".to_string(), - }), - }); - - assert!(chat.bottom_pane.status_indicator_visible()); - assert_eq!(chat.current_status_header, "Waiting for `vim`"); - assert!(drain_insert_history(&mut rx).is_empty()); -} - -#[tokio::test] -async fn apply_patch_events_emit_history_cells() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // 1) Approval request -> proposed patch summary cell - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - changes, - reason: None, - grant_root: None, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected approval request to surface via modal without emitting history cells" - ); - - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - let mut saw_summary = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("foo.txt (+1 -0)") { - saw_summary = true; - break; - } - } - assert!(saw_summary, "expected approval modal to show diff summary"); - - // 2) Begin apply -> per-file apply block cell (no global header) - let mut changes2 = HashMap::new(); - changes2.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - let begin = PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: true, - changes: changes2, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(begin), - }); - let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected apply block cell to be sent"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!( - blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), - "expected single-file header with filename (Added/Edited): {blob:?}" - ); - - // 3) End apply success -> success cell - let mut end_changes = HashMap::new(); - end_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - let end = PatchApplyEndEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - stdout: "ok\n".into(), - stderr: String::new(), - success: true, - changes: end_changes, - }; - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyEnd(end), - }); - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "no success cell should be emitted anymore" - ); -} - -#[tokio::test] -async fn apply_patch_manual_approval_adjusts_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let mut proposed_changes = HashMap::new(); - proposed_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - changes: proposed_changes, - reason: None, - grant_root: None, - }), - }); - drain_insert_history(&mut rx); - - let mut apply_changes = HashMap::new(); - apply_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: false, - changes: apply_changes, - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected apply block cell to be sent"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!( - blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"), - "expected apply summary header for foo.txt: {blob:?}" - ); -} - -#[tokio::test] -async fn apply_patch_manual_flow_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - let mut proposed_changes = HashMap::new(); - proposed_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - changes: proposed_changes, - reason: Some("Manual review required".into()), - grant_root: None, - }), - }); - let history_before_apply = drain_insert_history(&mut rx); - assert!( - history_before_apply.is_empty(), - "expected approval modal to defer history emission" - ); - - let mut apply_changes = HashMap::new(); - apply_changes.insert( - PathBuf::from("foo.txt"), - FileChange::Add { - content: "hello\n".to_string(), - }, - ); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "c1".into(), - turn_id: "turn-c1".into(), - auto_approved: false, - changes: apply_changes, - }), - }); - let approved_lines = drain_insert_history(&mut rx) - .pop() - .expect("approved patch cell"); - - assert_snapshot!( - "apply_patch_manual_flow_history_approved", - lines_to_single_string(&approved_lines) - ); -} - -#[tokio::test] -async fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - // Simulate receiving an approval request with a distinct submission id and call id - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("file.rs"), - FileChange::Add { - content: "fn main(){}\n".into(), - }, - ); - let ev = ApplyPatchApprovalRequestEvent { - call_id: "call-999".into(), - turn_id: "turn-999".into(), - changes, - reason: None, - grant_root: None, - }; - chat.handle_codex_event(Event { - id: "sub-123".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ev), - }); - - // Approve via key press 'y' - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - - // Expect a CodexOp with PatchApproval carrying the submission id, not call id - let mut found = false; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::CodexOp(Op::PatchApproval { id, decision }) = app_ev { - assert_eq!(id, "sub-123"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); - found = true; - break; - } - } - assert!(found, "expected PatchApproval op to be sent"); -} - -#[tokio::test] -async fn apply_patch_full_flow_integration_like() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; - - // 1) Backend requests approval - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("pkg.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - changes, - reason: None, - grant_root: None, - }), - }); - - // 2) User approves via 'y' and App receives a CodexOp - chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - let mut maybe_op: Option = None; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::CodexOp(op) = app_ev { - maybe_op = Some(op); - break; - } - } - let op = maybe_op.expect("expected CodexOp after key press"); - - // 3) App forwards to widget.submit_op, which pushes onto codex_op_tx - chat.submit_op(op); - let forwarded = op_rx - .try_recv() - .expect("expected op forwarded to codex channel"); - match forwarded { - Op::PatchApproval { id, decision } => { - assert_eq!(id, "sub-xyz"); - assert_matches!(decision, codex_core::protocol::ReviewDecision::Approved); - } - other => panic!("unexpected op forwarded: {other:?}"), - } - - // 4) Simulate patch begin/end events from backend; ensure history cells are emitted - let mut changes2 = HashMap::new(); - changes2.insert( - PathBuf::from("pkg.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - auto_approved: false, - changes: changes2, - }), - }); - let mut end_changes = HashMap::new(); - end_changes.insert( - PathBuf::from("pkg.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-xyz".into(), - msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - stdout: String::from("ok"), - stderr: String::new(), - success: true, - changes: end_changes, - }), - }); -} - -#[tokio::test] -async fn apply_patch_untrusted_shows_approval_modal() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - // Ensure approval policy is untrusted (OnRequest) - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - // Simulate a patch approval request from backend - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("a.rs"), - FileChange::Add { content: "".into() }, - ); - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "call-1".into(), - turn_id: "turn-call-1".into(), - changes, - reason: None, - grant_root: None, - }), - }); - - // Render and ensure the approval modal title is present - let area = Rect::new(0, 0, 80, 12); - let mut buf = Buffer::empty(area); - chat.render(area, &mut buf); - - let mut contains_title = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("Would you like to make the following edits?") { - contains_title = true; - break; - } - } - assert!( - contains_title, - "expected approval modal to be visible with title 'Would you like to make the following edits?'" - ); -} - -#[tokio::test] -async fn apply_patch_request_shows_diff_summary() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Ensure we are in OnRequest so an approval is surfaced - chat.config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); - - // Simulate backend asking to apply a patch adding two lines to README.md - let mut changes = HashMap::new(); - changes.insert( - PathBuf::from("README.md"), - FileChange::Add { - // Two lines (no trailing empty line counted) - content: "line one\nline two\n".into(), - }, - ); - chat.handle_codex_event(Event { - id: "sub-apply".into(), - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id: "call-apply".into(), - turn_id: "turn-apply".into(), - changes, - reason: None, - grant_root: None, - }), - }); - - // No history entries yet; the modal should contain the diff summary - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected approval request to render via modal instead of history" - ); - - let area = Rect::new(0, 0, 80, chat.desired_height(80)); - let mut buf = ratatui::buffer::Buffer::empty(area); - chat.render(area, &mut buf); - - let mut saw_header = false; - let mut saw_line1 = false; - let mut saw_line2 = false; - for y in 0..area.height { - let mut row = String::new(); - for x in 0..area.width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - if row.contains("README.md (+2 -0)") { - saw_header = true; - } - if row.contains("+line one") { - saw_line1 = true; - } - if row.contains("+line two") { - saw_line2 = true; - } - if saw_header && saw_line1 && saw_line2 { - break; - } - } - assert!(saw_header, "expected modal to show diff header with totals"); - assert!( - saw_line1 && saw_line2, - "expected modal to show per-line diff summary" - ); -} - -#[tokio::test] -async fn plan_update_renders_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let update = UpdatePlanArgs { - explanation: Some("Adapting plan".to_string()), - plan: vec![ - PlanItemArg { - step: "Explore codebase".into(), - status: StepStatus::Completed, - }, - PlanItemArg { - step: "Implement feature".into(), - status: StepStatus::InProgress, - }, - PlanItemArg { - step: "Write tests".into(), - status: StepStatus::Pending, - }, - ], - }; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::PlanUpdate(update), - }); - let cells = drain_insert_history(&mut rx); - assert!(!cells.is_empty(), "expected plan update cell to be sent"); - let blob = lines_to_single_string(cells.last().unwrap()); - assert!( - blob.contains("Updated Plan"), - "missing plan header: {blob:?}" - ); - assert!(blob.contains("Explore codebase")); - assert!(blob.contains("Implement feature")); - assert!(blob.contains("Write tests")); -} - -#[tokio::test] -async fn stream_error_updates_status_indicator() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.bottom_pane.set_task_running(true); - let msg = "Reconnecting... 2/5"; - let details = "Idle timeout waiting for SSE"; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: msg.to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: Some(details.to_string()), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected no history cell for StreamError event" - ); - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be visible"); - assert_eq!(status.header(), msg); - assert_eq!(status.details(), Some(details)); -} - -#[tokio::test] -async fn warning_event_adds_warning_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "sub-1".into(), - msg: EventMsg::Warning(WarningEvent { - message: "test warning message".to_string(), - }), - }); - - let cells = drain_insert_history(&mut rx); - assert_eq!(cells.len(), 1, "expected one warning history cell"); - let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("test warning message"), - "warning cell missing content: {rendered}" - ); -} - -#[tokio::test] -async fn stream_recovery_restores_previous_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "task".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "retry".into(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: "Reconnecting... 1/5".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), - }); - drain_insert_history(&mut rx); - chat.handle_codex_event(Event { - id: "delta".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "hello".to_string(), - }), - }); - - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be visible"); - assert_eq!(status.header(), "Working"); - assert_eq!(status.details(), None); - assert!(chat.retry_status_header.is_none()); -} - -#[tokio::test] -async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Begin turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - - // First finalized assistant message - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "First message".into(), - }), - }); - - // Second finalized assistant message in the same turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Second message".into(), - }), - }); - - // End turn - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - }), - }); - - let cells = drain_insert_history(&mut rx); - let combined: String = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect(); - assert!( - combined.contains("First message"), - "missing first message: {combined}" - ); - assert!( - combined.contains("Second message"), - "missing second message: {combined}" - ); - let first_idx = combined.find("First message").unwrap(); - let second_idx = combined.find("Second message").unwrap(); - assert!(first_idx < second_idx, "messages out of order: {combined}"); -} - -#[tokio::test] -async fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // No deltas; only final reasoning followed by final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "I will first analyze the request.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - }), - }); - - // Drain history and snapshot the combined visible content. - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert_snapshot!(combined); -} - -#[tokio::test] -async fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Stream some reasoning deltas first. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "I will ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "first analyze the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "request.".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentReasoning(AgentReasoningEvent { - text: "request.".into(), - }), - }); - - // Then stream answer deltas, followed by the exact same final message. - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "Here is the ".into(), - }), - }); - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { - delta: "result.".into(), - }), - }); - - chat.handle_codex_event(Event { - id: "s1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Here is the result.".into(), - }), - }); - - // Snapshot the combined visible content to ensure we render as expected - // when deltas are followed by the identical final message. - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert_snapshot!(combined); -} - -// Combined visual snapshot using vt100 for history + direct buffer overlay for UI. -// This renders the final visual as seen in a terminal: history above, then a blank line, -// then the exec block, another blank line, the status line, a blank line, and the composer. -#[tokio::test] -async fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }), - }); - - let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()]; - let parsed_cmd = vec![ - ParsedCommand::Search { - query: Some("Change Approved".into()), - path: None, - cmd: "rg \"Change Approved\"".into(), - }, - ParsedCommand::Read { - name: "diff_render.rs".into(), - cmd: "cat diff_render.rs".into(), - path: "diff_render.rs".into(), - }, - ]; - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - }); - chat.handle_codex_event(Event { - id: "c1".into(), - msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "c1".into(), - process_id: None, - turn_id: "turn-1".into(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: std::time::Duration::from_millis(16000), - formatted_output: String::new(), - }), - }); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Investigating rendering code**".into(), - }), - }); - chat.bottom_pane - .set_composer_text("Summarize recent commits".to_string()); - - let width: u16 = 80; - let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 40; - let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); - - let backend = VT100Backend::new(width, vt_height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - term.set_viewport_area(viewport); - - for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - } - - term.draw(|f| { - chat.render(f.area(), f.buffer_mut()); - }) - .unwrap(); - - assert_snapshot!(term.backend().vt100().screen().contents()); -} - -// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks -#[tokio::test] -async fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - - // Simulate a final agent message via streaming deltas instead of a single message - - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - // Build a vt100 visual from the history insertions only (no UI overlay) - let width: u16 = 80; - let height: u16 = 50; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - // Place viewport at the last line so that history lines insert above it - term.set_viewport_area(Rect::new(0, height - 1, width, 1)); - - // Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage). - let source: &str = r#" - - -- Indented code block (4 spaces) - SELECT * - FROM "users" - WHERE "email" LIKE '%@example.com'; - -````markdown -```sh -printf 'fenced within fenced\n' -``` -```` - -```jsonc -{ - // comment allowed in jsonc - "path": "C:\\Program Files\\App", - "regex": "^foo.*(bar)?$" -} -``` -"#; - - let mut it = source.chars(); - loop { - let mut delta = String::new(); - match it.next() { - Some(c) => delta.push(c), - None => break, - } - if let Some(c2) = it.next() { - delta.push(c2); - } - - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), - }); - // Drive commit ticks and drain emitted history lines into the vt100 buffer. - loop { - chat.on_commit_tick(); - let mut inserted_any = false; - while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = app_ev { - let lines = cell.display_lines(width); - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - inserted_any = true; - } - } - if !inserted_any { - break; - } - } - } - - // Finalize the stream without sending a final AgentMessage, to flush any tail. - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message: None, - }), - }); - for lines in drain_insert_history(&mut rx) { - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - } - - assert_snapshot!(term.backend().vt100().screen().contents()); -} - -#[tokio::test] -async fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.conversation_id = Some(ThreadId::new()); - chat.handle_codex_event(Event { - id: "t1".into(), - msg: EventMsg::TurnStarted(TurnStartedEvent { - model_context_window: None, - }), - }); - for i in 0..30 { - chat.queue_user_message(format!("Hello, world! {i}").into()); - } - let width: u16 = 80; - let height: u16 = 24; - let backend = VT100Backend::new(width, height); - let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); - let desired_height = chat.desired_height(width).min(height); - term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); - term.draw(|f| { - chat.render(f.area(), f.buffer_mut()); - }) - .unwrap(); - assert_snapshot!(term.backend().vt100().screen().contents()); -} diff --git a/codex-rs/tui2/src/cli.rs b/codex-rs/tui2/src/cli.rs deleted file mode 100644 index 77903c7046..0000000000 --- a/codex-rs/tui2/src/cli.rs +++ /dev/null @@ -1,142 +0,0 @@ -use clap::Parser; -use clap::ValueHint; -use codex_common::ApprovalModeCliArg; -use codex_common::CliConfigOverrides; -use std::path::PathBuf; - -#[derive(Parser, Debug)] -#[command(version)] -pub struct Cli { - /// Optional user prompt to start the session. - #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] - pub prompt: Option, - - /// Optional image(s) to attach to the initial prompt. - #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] - pub images: Vec, - - // Internal controls set by the top-level `codex resume` subcommand. - // These are not exposed as user flags on the base `codex` command. - #[clap(skip)] - pub resume_picker: bool, - - #[clap(skip)] - pub resume_last: bool, - - /// Internal: resume a specific recorded session by id (UUID). Set by the - /// top-level `codex resume ` wrapper; not exposed as a public flag. - #[clap(skip)] - pub resume_session_id: Option, - - /// Internal: show all sessions (disables cwd filtering and shows CWD column). - #[clap(skip)] - pub resume_show_all: bool, - - // Internal controls set by the top-level `codex fork` subcommand. - // These are not exposed as user flags on the base `codex` command. - #[clap(skip)] - pub fork_picker: bool, - - #[clap(skip)] - pub fork_last: bool, - - /// Internal: fork a specific recorded session by id (UUID). Set by the - /// top-level `codex fork ` wrapper; not exposed as a public flag. - #[clap(skip)] - pub fork_session_id: Option, - - /// Internal: show all sessions (disables cwd filtering and shows CWD column). - #[clap(skip)] - pub fork_show_all: bool, - - /// Model the agent should use. - #[arg(long, short = 'm')] - pub model: Option, - - /// Convenience flag to select the local open source model provider. Equivalent to -c - /// model_provider=oss; verifies a local LM Studio or Ollama server is running. - #[arg(long = "oss", default_value_t = false)] - pub oss: bool, - - /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). - /// If not specified with --oss, will use config default or show selection. - #[arg(long = "local-provider")] - pub oss_provider: Option, - - /// Configuration profile from config.toml to specify default options. - #[arg(long = "profile", short = 'p')] - pub config_profile: Option, - - /// Select the sandbox policy to use when executing model-generated shell - /// commands. - #[arg(long = "sandbox", short = 's')] - pub sandbox_mode: Option, - - /// Configure when the model requires human approval before executing a command. - #[arg(long = "ask-for-approval", short = 'a')] - pub approval_policy: Option, - - /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). - #[arg(long = "full-auto", default_value_t = false)] - pub full_auto: bool, - - /// Skip all confirmation prompts and execute commands without sandboxing. - /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. - #[arg( - long = "dangerously-bypass-approvals-and-sandbox", - alias = "yolo", - default_value_t = false, - conflicts_with_all = ["approval_policy", "full_auto"] - )] - pub dangerously_bypass_approvals_and_sandbox: bool, - - /// Tell the agent to use the specified directory as its working root. - #[clap(long = "cd", short = 'C', value_name = "DIR")] - pub cwd: Option, - - /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). - #[arg(long = "search", default_value_t = false)] - pub web_search: bool, - - /// Additional directories that should be writable alongside the primary workspace. - #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] - pub add_dir: Vec, - - /// Disable alternate screen mode for better scrollback in terminal multiplexers like Zellij. - /// This runs the TUI in inline mode, preserving terminal scrollback history. - #[arg(long = "no-alt-screen", default_value_t = false)] - pub no_alt_screen: bool, - - #[clap(skip)] - pub config_overrides: CliConfigOverrides, -} - -impl From for Cli { - fn from(cli: codex_tui::Cli) -> Self { - Self { - prompt: cli.prompt, - images: cli.images, - resume_picker: cli.resume_picker, - resume_last: cli.resume_last, - resume_session_id: cli.resume_session_id, - resume_show_all: cli.resume_show_all, - fork_picker: cli.fork_picker, - fork_last: cli.fork_last, - fork_session_id: cli.fork_session_id, - fork_show_all: cli.fork_show_all, - model: cli.model, - oss: cli.oss, - oss_provider: cli.oss_provider, - config_profile: cli.config_profile, - sandbox_mode: cli.sandbox_mode, - approval_policy: cli.approval_policy, - full_auto: cli.full_auto, - dangerously_bypass_approvals_and_sandbox: cli.dangerously_bypass_approvals_and_sandbox, - cwd: cli.cwd, - web_search: cli.web_search, - add_dir: cli.add_dir, - no_alt_screen: cli.no_alt_screen, - config_overrides: cli.config_overrides, - } - } -} diff --git a/codex-rs/tui2/src/clipboard_copy.rs b/codex-rs/tui2/src/clipboard_copy.rs deleted file mode 100644 index 76718704e5..0000000000 --- a/codex-rs/tui2/src/clipboard_copy.rs +++ /dev/null @@ -1,79 +0,0 @@ -use tracing::error; - -#[derive(Debug)] -pub enum ClipboardError { - ClipboardUnavailable(String), - WriteFailed(String), -} - -impl std::fmt::Display for ClipboardError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ClipboardError::ClipboardUnavailable(msg) => { - write!(f, "clipboard unavailable: {msg}") - } - ClipboardError::WriteFailed(msg) => write!(f, "failed to write to clipboard: {msg}"), - } - } -} - -impl std::error::Error for ClipboardError {} - -pub trait ClipboardManager { - fn set_text(&mut self, text: String) -> Result<(), ClipboardError>; -} - -#[cfg(not(target_os = "android"))] -pub struct ArboardClipboardManager { - inner: Option, -} - -#[cfg(not(target_os = "android"))] -impl ArboardClipboardManager { - pub fn new() -> Self { - match arboard::Clipboard::new() { - Ok(cb) => Self { inner: Some(cb) }, - Err(err) => { - error!(error = %err, "failed to initialize clipboard"); - Self { inner: None } - } - } - } -} - -#[cfg(not(target_os = "android"))] -impl ClipboardManager for ArboardClipboardManager { - fn set_text(&mut self, text: String) -> Result<(), ClipboardError> { - let Some(cb) = &mut self.inner else { - return Err(ClipboardError::ClipboardUnavailable( - "clipboard is not available in this environment".to_string(), - )); - }; - cb.set_text(text) - .map_err(|e| ClipboardError::WriteFailed(e.to_string())) - } -} - -#[cfg(target_os = "android")] -pub struct ArboardClipboardManager; - -#[cfg(target_os = "android")] -impl ArboardClipboardManager { - pub fn new() -> Self { - ArboardClipboardManager - } -} - -#[cfg(target_os = "android")] -impl ClipboardManager for ArboardClipboardManager { - fn set_text(&mut self, _text: String) -> Result<(), ClipboardError> { - Err(ClipboardError::ClipboardUnavailable( - "clipboard text copy is unsupported on Android".to_string(), - )) - } -} - -pub fn copy_text(text: String) -> Result<(), ClipboardError> { - let mut manager = ArboardClipboardManager::new(); - manager.set_text(text) -} diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs deleted file mode 100644 index 4d28b365fe..0000000000 --- a/codex-rs/tui2/src/clipboard_paste.rs +++ /dev/null @@ -1,549 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use tempfile::Builder; - -#[derive(Debug, Clone)] -pub enum PasteImageError { - ClipboardUnavailable(String), - NoImage(String), - EncodeFailed(String), - IoError(String), -} - -impl std::fmt::Display for PasteImageError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PasteImageError::ClipboardUnavailable(msg) => write!(f, "clipboard unavailable: {msg}"), - PasteImageError::NoImage(msg) => write!(f, "no image on clipboard: {msg}"), - PasteImageError::EncodeFailed(msg) => write!(f, "could not encode image: {msg}"), - PasteImageError::IoError(msg) => write!(f, "io error: {msg}"), - } - } -} -impl std::error::Error for PasteImageError {} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncodedImageFormat { - Png, - Jpeg, - Other, -} - -impl EncodedImageFormat { - pub fn label(self) -> &'static str { - match self { - EncodedImageFormat::Png => "PNG", - EncodedImageFormat::Jpeg => "JPEG", - EncodedImageFormat::Other => "IMG", - } - } -} - -#[derive(Debug, Clone)] -pub struct PastedImageInfo { - pub width: u32, - pub height: u32, - pub encoded_format: EncodedImageFormat, // Always PNG for now. -} - -/// Capture image from system clipboard, encode to PNG, and return bytes + info. -#[cfg(not(target_os = "android"))] -pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { - let _span = tracing::debug_span!("paste_image_as_png").entered(); - tracing::debug!("attempting clipboard image read"); - let mut cb = arboard::Clipboard::new() - .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string()))?; - // Sometimes images on the clipboard come as files (e.g. when copy/pasting from - // Finder), sometimes they come as image data (e.g. when pasting from Chrome). - // Accept both, and prefer files if both are present. - let files = cb - .get() - .file_list() - .map_err(|e| PasteImageError::ClipboardUnavailable(e.to_string())); - let dyn_img = if let Some(img) = files - .unwrap_or_default() - .into_iter() - .find_map(|f| image::open(f).ok()) - { - tracing::debug!( - "clipboard image opened from file: {}x{}", - img.width(), - img.height() - ); - img - } else { - let _span = tracing::debug_span!("get_image").entered(); - let img = cb - .get_image() - .map_err(|e| PasteImageError::NoImage(e.to_string()))?; - let w = img.width as u32; - let h = img.height as u32; - tracing::debug!("clipboard image opened from image: {}x{}", w, h); - - let Some(rgba_img) = image::RgbaImage::from_raw(w, h, img.bytes.into_owned()) else { - return Err(PasteImageError::EncodeFailed("invalid RGBA buffer".into())); - }; - - image::DynamicImage::ImageRgba8(rgba_img) - }; - - let mut png: Vec = Vec::new(); - { - let span = - tracing::debug_span!("encode_image", byte_length = tracing::field::Empty).entered(); - let mut cursor = std::io::Cursor::new(&mut png); - dyn_img - .write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; - span.record("byte_length", png.len()); - } - - Ok(( - png, - PastedImageInfo { - width: dyn_img.width(), - height: dyn_img.height(), - encoded_format: EncodedImageFormat::Png, - }, - )) -} - -/// Android/Termux does not support arboard; return a clear error. -#[cfg(target_os = "android")] -pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { - Err(PasteImageError::ClipboardUnavailable( - "clipboard image paste is unsupported on Android".into(), - )) -} - -/// Convenience: write to a temp file and return its path + info. -#[cfg(not(target_os = "android"))] -pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - // First attempt: read image from system clipboard via arboard (native paths or image data). - match paste_image_as_png() { - Ok((png, info)) => { - // Create a unique temporary file with a .png suffix to avoid collisions. - let tmp = Builder::new() - .prefix("codex-clipboard-") - .suffix(".png") - .tempfile() - .map_err(|e| PasteImageError::IoError(e.to_string()))?; - std::fs::write(tmp.path(), &png) - .map_err(|e| PasteImageError::IoError(e.to_string()))?; - // Persist the file (so it remains after the handle is dropped) and return its PathBuf. - let (_file, path) = tmp - .keep() - .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; - Ok((path, info)) - } - Err(e) => { - #[cfg(target_os = "linux")] - { - try_wsl_clipboard_fallback(&e).or(Err(e)) - } - #[cfg(not(target_os = "linux"))] - { - Err(e) - } - } - } -} - -/// Attempt WSL fallback for clipboard image paste. -/// -/// If clipboard is unavailable (common under WSL because arboard cannot access -/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the -/// Windows side to write the clipboard image to a temporary file, then return -/// the corresponding WSL path. -#[cfg(target_os = "linux")] -fn try_wsl_clipboard_fallback( - error: &PasteImageError, -) -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - use PasteImageError::ClipboardUnavailable; - use PasteImageError::NoImage; - - if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) { - return Err(error.clone()); - } - - tracing::debug!("attempting Windows PowerShell clipboard fallback"); - let Some(win_path) = try_dump_windows_clipboard_image() else { - return Err(error.clone()); - }; - - tracing::debug!("powershell produced path: {}", win_path); - let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else { - return Err(error.clone()); - }; - - let Ok((w, h)) = image::image_dimensions(&mapped_path) else { - return Err(error.clone()); - }; - - // Return the mapped path directly without copying. - // The file will be read and base64-encoded during serialization. - Ok(( - mapped_path, - PastedImageInfo { - width: w, - height: h, - encoded_format: EncodedImageFormat::Png, - }, - )) -} - -/// Try to call a Windows PowerShell command (several common names) to save the -/// clipboard image to a temporary PNG and return the Windows path to that file. -/// Returns None if no command succeeded or no image was present. -#[cfg(target_os = "linux")] -fn try_dump_windows_clipboard_image() -> Option { - // Powershell script: save image from clipboard to a temp png and print the path. - // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default) - // and pwsh (UTF-8 default). - let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#; - - for cmd in ["powershell.exe", "pwsh", "powershell"] { - match std::process::Command::new(cmd) - .args(["-NoProfile", "-Command", script]) - .output() - { - // Executing PowerShell command - Ok(output) => { - if output.status.success() { - // Decode as UTF-8 (forced by the script above). - let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !win_path.is_empty() { - tracing::debug!("{} saved clipboard image to {}", cmd, win_path); - return Some(win_path); - } - } else { - tracing::debug!("{} returned non-zero status", cmd); - } - } - Err(err) => { - tracing::debug!("{} not executable: {}", cmd, err); - } - } - } - None -} - -#[cfg(target_os = "android")] -pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - // Keep error consistent with paste_image_as_png. - Err(PasteImageError::ClipboardUnavailable( - "clipboard image paste is unsupported on Android".into(), - )) -} - -/// Normalize pasted text that may represent a filesystem path. -/// -/// Supports: -/// - `file://` URLs (converted to local paths) -/// - Windows/UNC paths -/// - shell-escaped single paths (via `shlex`) -pub fn normalize_pasted_path(pasted: &str) -> Option { - let pasted = pasted.trim(); - let unquoted = pasted - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .or_else(|| pasted.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) - .unwrap_or(pasted); - - // file:// URL → filesystem path - if let Ok(url) = url::Url::parse(unquoted) - && url.scheme() == "file" - { - return url.to_file_path().ok(); - } - - // TODO: We'll improve the implementation/unit tests over time, as appropriate. - // Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e - // - // Detect unquoted Windows paths and bypass POSIX shlex which - // treats backslashes as escapes (e.g., C:\Users\Alice\file.png). - // Also handles UNC paths (\\server\share\path). - if let Some(path) = normalize_windows_path(unquoted) { - return Some(path); - } - - // shell-escaped single path → unescaped - let parts: Vec = shlex::Shlex::new(pasted).collect(); - if parts.len() == 1 { - let part = parts.into_iter().next()?; - if let Some(path) = normalize_windows_path(&part) { - return Some(path); - } - return Some(PathBuf::from(part)); - } - - None -} - -#[cfg(target_os = "linux")] -pub(crate) fn is_probably_wsl() -> bool { - // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). - if let Ok(version) = std::fs::read_to_string("/proc/version") { - let version_lower = version.to_lowercase(); - if version_lower.contains("microsoft") || version_lower.contains("wsl") { - return true; - } - } - - // Fallback: Check WSL environment variables. This handles edge cases like - // custom Linux kernels installed in WSL where /proc/version may not contain - // "microsoft" or "WSL". - std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() -} - -#[cfg(target_os = "linux")] -fn convert_windows_path_to_wsl(input: &str) -> Option { - if input.starts_with("\\\\") { - return None; - } - - let drive_letter = input.chars().next()?.to_ascii_lowercase(); - if !drive_letter.is_ascii_lowercase() { - return None; - } - - if input.get(1..2) != Some(":") { - return None; - } - - let mut result = PathBuf::from(format!("/mnt/{drive_letter}")); - for component in input - .get(2..)? - .trim_start_matches(['\\', '/']) - .split(['\\', '/']) - .filter(|component| !component.is_empty()) - { - result.push(component); - } - - Some(result) -} - -fn normalize_windows_path(input: &str) -> Option { - // Drive letter path: C:\ or C:/ - let drive = input - .chars() - .next() - .map(|c| c.is_ascii_alphabetic()) - .unwrap_or(false) - && input.get(1..2) == Some(":") - && input - .get(2..3) - .map(|s| s == "\\" || s == "/") - .unwrap_or(false); - // UNC path: \\server\share - let unc = input.starts_with("\\\\"); - if !drive && !unc { - return None; - } - - #[cfg(target_os = "linux")] - { - if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(input) - { - return Some(converted); - } - } - - Some(PathBuf::from(input)) -} - -/// Infer an image format for the provided path based on its extension. -pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { - match path - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("png") => EncodedImageFormat::Png, - Some("jpg") | Some("jpeg") => EncodedImageFormat::Jpeg, - _ => EncodedImageFormat::Other, - } -} - -#[cfg(test)] -mod pasted_paths_tests { - use super::*; - - #[cfg(not(windows))] - #[test] - fn normalize_file_url() { - let input = "file:///tmp/example.png"; - let result = normalize_pasted_path(input).expect("should parse file URL"); - assert_eq!(result, PathBuf::from("/tmp/example.png")); - } - - #[test] - fn normalize_file_url_windows() { - let input = r"C:\Temp\example.png"; - let result = normalize_pasted_path(input).expect("should parse file URL"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(input) - { - converted - } else { - PathBuf::from(r"C:\Temp\example.png") - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(r"C:\Temp\example.png"); - assert_eq!(result, expected); - } - - #[test] - fn normalize_shell_escaped_single_path() { - let input = "/home/user/My\\ File.png"; - let result = normalize_pasted_path(input).expect("should unescape shell-escaped path"); - assert_eq!(result, PathBuf::from("/home/user/My File.png")); - } - - #[test] - fn normalize_simple_quoted_path_fallback() { - let input = "\"/home/user/My File.png\""; - let result = normalize_pasted_path(input).expect("should trim simple quotes"); - assert_eq!(result, PathBuf::from("/home/user/My File.png")); - } - - #[test] - fn normalize_single_quoted_unix_path() { - let input = "'/home/user/My File.png'"; - let result = normalize_pasted_path(input).expect("should trim single quotes via shlex"); - assert_eq!(result, PathBuf::from("/home/user/My File.png")); - } - - #[test] - fn normalize_multiple_tokens_returns_none() { - // Two tokens after shell splitting → not a single path - let input = "/home/user/a\\ b.png /home/user/c.png"; - let result = normalize_pasted_path(input); - assert!(result.is_none()); - } - - #[test] - fn pasted_image_format_png_jpeg_unknown() { - assert_eq!( - pasted_image_format(Path::new("/a/b/c.PNG")), - EncodedImageFormat::Png - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c.jpg")), - EncodedImageFormat::Jpeg - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c.JPEG")), - EncodedImageFormat::Jpeg - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c")), - EncodedImageFormat::Other - ); - assert_eq!( - pasted_image_format(Path::new("/a/b/c.webp")), - EncodedImageFormat::Other - ); - } - - #[test] - fn normalize_single_quoted_windows_path() { - let input = r"'C:\\Users\\Alice\\My File.jpeg'"; - let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; - let result = - normalize_pasted_path(input).expect("should trim single quotes on windows path"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(unquoted) - { - converted - } else { - PathBuf::from(unquoted) - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(unquoted); - assert_eq!(result, expected); - } - - #[test] - fn normalize_double_quoted_windows_path() { - let input = r#""C:\\Users\\Alice\\My File.jpeg""#; - let unquoted = r"C:\\Users\\Alice\\My File.jpeg"; - let result = - normalize_pasted_path(input).expect("should trim double quotes on windows path"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(unquoted) - { - converted - } else { - PathBuf::from(unquoted) - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(unquoted); - assert_eq!(result, expected); - } - - #[test] - fn normalize_unquoted_windows_path_with_spaces() { - let input = r"C:\\Users\\Alice\\My Pictures\\example image.png"; - let result = normalize_pasted_path(input).expect("should accept unquoted windows path"); - #[cfg(target_os = "linux")] - let expected = if is_probably_wsl() - && let Some(converted) = convert_windows_path_to_wsl(input) - { - converted - } else { - PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png") - }; - #[cfg(not(target_os = "linux"))] - let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png"); - assert_eq!(result, expected); - } - - #[test] - fn normalize_unc_windows_path() { - let input = r"\\\\server\\share\\folder\\file.jpg"; - let result = normalize_pasted_path(input).expect("should accept UNC windows path"); - assert_eq!( - result, - PathBuf::from(r"\\\\server\\share\\folder\\file.jpg") - ); - } - - #[test] - fn pasted_image_format_with_windows_style_paths() { - assert_eq!( - pasted_image_format(Path::new(r"C:\\a\\b\\c.PNG")), - EncodedImageFormat::Png - ); - assert_eq!( - pasted_image_format(Path::new(r"C:\\a\\b\\c.jpeg")), - EncodedImageFormat::Jpeg - ); - assert_eq!( - pasted_image_format(Path::new(r"C:\\a\\b\\noext")), - EncodedImageFormat::Other - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn normalize_windows_path_in_wsl() { - // This test only runs on actual WSL systems - if !is_probably_wsl() { - // Skip test if not on WSL - return; - } - let input = r"C:\\Users\\Alice\\Pictures\\example image.png"; - let result = normalize_pasted_path(input).expect("should convert windows path on wsl"); - assert_eq!( - result, - PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png") - ); - } -} diff --git a/codex-rs/tui2/src/collab.rs b/codex-rs/tui2/src/collab.rs deleted file mode 100644 index 5a1a18f63c..0000000000 --- a/codex-rs/tui2/src/collab.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::history_cell::PlainHistoryCell; -use crate::render::line_utils::prefix_lines; -use crate::text_formatting::truncate_text; -use codex_core::protocol::AgentStatus; -use codex_core::protocol::CollabAgentInteractionEndEvent; -use codex_core::protocol::CollabAgentSpawnEndEvent; -use codex_core::protocol::CollabCloseEndEvent; -use codex_core::protocol::CollabWaitingBeginEvent; -use codex_core::protocol::CollabWaitingEndEvent; -use ratatui::style::Stylize; -use ratatui::text::Line; - -const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; - -pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { - let CollabAgentSpawnEndEvent { - call_id, - sender_thread_id, - new_thread_id, - prompt, - status, - } = ev; - let new_agent = new_thread_id - .map(|id| id.to_string()) - .unwrap_or_else(|| "none".to_string()); - let mut details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("new_agent", new_agent), - status_line(&status), - ]; - if let Some(line) = prompt_line(&prompt) { - details.push(line); - } - collab_event("Collab spawn", details) -} - -pub(crate) fn interaction_end(ev: CollabAgentInteractionEndEvent) -> PlainHistoryCell { - let CollabAgentInteractionEndEvent { - call_id, - sender_thread_id, - receiver_thread_id, - prompt, - status, - } = ev; - let mut details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), - ]; - if let Some(line) = prompt_line(&prompt) { - details.push(line); - } - collab_event("Collab send input", details) -} - -pub(crate) fn waiting_begin(ev: CollabWaitingBeginEvent) -> PlainHistoryCell { - let CollabWaitingBeginEvent { - call_id, - sender_thread_id, - receiver_thread_ids, - } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", format!("{receiver_thread_ids:?}")), - ]; - collab_event("Collab wait begin", details) -} - -pub(crate) fn waiting_end(ev: CollabWaitingEndEvent) -> PlainHistoryCell { - let CollabWaitingEndEvent { - call_id, - sender_thread_id, - statuses, - } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("statuses", format!("{statuses:#?}")), - ]; - collab_event("Collab wait end", details) -} - -pub(crate) fn close_end(ev: CollabCloseEndEvent) -> PlainHistoryCell { - let CollabCloseEndEvent { - call_id, - sender_thread_id, - receiver_thread_id, - status, - } = ev; - let details = vec![ - detail_line("call", call_id), - detail_line("sender", sender_thread_id), - detail_line("receiver", receiver_thread_id), - status_line(&status), - ]; - collab_event("Collab close", details) -} - -fn collab_event(title: impl Into, details: Vec>) -> PlainHistoryCell { - let title = title.into(); - let mut lines: Vec> = vec![vec!["• ".dim(), title.bold()].into()]; - if !details.is_empty() { - lines.extend(prefix_lines(details, " └ ".dim(), " ".into())); - } - PlainHistoryCell::new(lines) -} - -fn detail_line(label: &str, value: impl std::fmt::Display) -> Line<'static> { - Line::from(format!("{label}: {value}").dim()) -} - -fn status_line(status: &AgentStatus) -> Line<'static> { - Line::from(format!("status: {}", status_text(status)).dim()) -} - -fn status_text(status: &AgentStatus) -> &'static str { - match status { - AgentStatus::PendingInit => "pending_init", - AgentStatus::Running => "running", - AgentStatus::Completed(_) => "completed", - AgentStatus::Errored(_) => "errored", - AgentStatus::Shutdown => "shutdown", - AgentStatus::NotFound => "not_found", - } -} - -fn prompt_line(prompt: &str) -> Option> { - let trimmed = prompt.trim(); - if trimmed.is_empty() { - None - } else { - Some(detail_line( - "prompt", - truncate_text(trimmed, COLLAB_PROMPT_PREVIEW_GRAPHEMES), - )) - } -} diff --git a/codex-rs/tui2/src/collaboration_modes.rs b/codex-rs/tui2/src/collaboration_modes.rs deleted file mode 100644 index bdd1d8b63c..0000000000 --- a/codex-rs/tui2/src/collaboration_modes.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Collaboration mode selection + rendering helpers for the TUI. -//! -//! This module is intentionally UI-focused: -//! - It owns the user-facing set of selectable collaboration modes and how they cycle. -//! - It parses `/collab ` arguments into a selection. -//! - It resolves a `Selection` to a concrete `codex_protocol::config_types::CollaborationMode` by -//! picking from the `ModelsManager` builtin collaboration presets. -//! - It builds the small footer "flash" line shown after changing modes. -//! -//! The `ChatWidget` owns the session state and decides *when* selection/mode changes are allowed -//! (feature flag, task running, modals open, etc.). This module just provides the building blocks. - -use crate::key_hint; -use codex_core::models_manager::manager::ModelsManager; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::Settings; -use codex_protocol::openai_models::ReasoningEffort; -use crossterm::event::KeyCode; -use ratatui::style::Stylize; -use ratatui::text::Line; - -/// The user-facing collaboration mode choices supported by the TUI. -/// -/// This is distinct from `CollaborationMode`: it represents a stable UI selection and the cycling -/// order, while `CollaborationMode` can carry nested settings/prompt configuration. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum Selection { - Plan, - #[default] - PairProgramming, - Execute, -} - -impl Selection { - /// Cycle to the next selection. - /// - /// The TUI cycles through a small, fixed set of presets. - pub(crate) fn next(self) -> Self { - match self { - Self::Plan => Self::PairProgramming, - Self::PairProgramming => Self::Execute, - Self::Execute => Self::Plan, - } - } - - /// User-facing label used in UI surfaces like `/status` and the footer flash. - pub(crate) fn label(self) -> &'static str { - match self { - Self::Plan => "Plan", - Self::PairProgramming => "Pair Programming", - Self::Execute => "Execute", - } - } -} - -/// Parse a user argument (e.g. `/collab plan`, `/collab pair_programming`) into a selection. -/// -/// The parser is forgiving: it strips whitespace, `-`, and `_`, and matches case-insensitively. -pub(crate) fn parse_selection(input: &str) -> Option { - let normalized: String = input - .chars() - .filter(|c| !c.is_ascii_whitespace() && *c != '-' && *c != '_') - .flat_map(char::to_lowercase) - .collect(); - - match normalized.as_str() { - "plan" => Some(Selection::Plan), - "pair" | "pairprogramming" | "pp" => Some(Selection::PairProgramming), - "execute" | "exec" => Some(Selection::Execute), - _ => None, - } -} - -/// Resolve a selection to a concrete collaboration mode preset. -/// -/// `ModelsManager::list_collaboration_modes()` is expected to return a builtin set of presets; this -/// function selects the first preset of the desired variant. -pub(crate) fn resolve_mode( - models_manager: &ModelsManager, - selection: Selection, -) -> Option { - match selection { - Selection::Plan => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Plan(_))), - Selection::PairProgramming => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::PairProgramming(_))), - Selection::Execute => models_manager - .list_collaboration_modes() - .into_iter() - .find(|mode| matches!(mode, CollaborationMode::Execute(_))), - } -} - -/// Resolve a selection to a concrete collaboration mode preset, falling back to a synthesized mode -/// when the desired preset is unavailable. -/// -/// This keeps the TUI behavior stable when collaboration presets are missing (for example, when -/// running in offline/unit-test contexts): if the feature flag is enabled, every submission carries -/// an explicit collaboration mode so core doesn't fall back to `Custom`. -pub(crate) fn resolve_mode_or_fallback( - models_manager: &ModelsManager, - selection: Selection, - fallback_model: &str, - fallback_effort: Option, -) -> CollaborationMode { - resolve_mode(models_manager, selection).unwrap_or_else(|| { - let settings = Settings { - model: fallback_model.to_string(), - reasoning_effort: fallback_effort, - developer_instructions: None, - }; - - match selection { - Selection::Plan => CollaborationMode::Plan(settings), - Selection::PairProgramming => CollaborationMode::PairProgramming(settings), - Selection::Execute => CollaborationMode::Execute(settings), - } - }) -} - -/// Build a 1-line footer "flash" that is shown after switching modes. -/// -/// The `ChatWidget` controls when to show this and how long it should remain visible. -pub(crate) fn flash_line(selection: Selection) -> Line<'static> { - Line::from(vec![ - selection.label().bold(), - " (".dim(), - key_hint::shift(KeyCode::Tab).into(), - " to change mode)".dim(), - ]) -} diff --git a/codex-rs/tui2/src/color.rs b/codex-rs/tui2/src/color.rs deleted file mode 100644 index f5121a1f6c..0000000000 --- a/codex-rs/tui2/src/color.rs +++ /dev/null @@ -1,75 +0,0 @@ -pub(crate) fn is_light(bg: (u8, u8, u8)) -> bool { - let (r, g, b) = bg; - let y = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32; - y > 128.0 -} - -pub(crate) fn blend(fg: (u8, u8, u8), bg: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { - let r = (fg.0 as f32 * alpha + bg.0 as f32 * (1.0 - alpha)) as u8; - let g = (fg.1 as f32 * alpha + bg.1 as f32 * (1.0 - alpha)) as u8; - let b = (fg.2 as f32 * alpha + bg.2 as f32 * (1.0 - alpha)) as u8; - (r, g, b) -} - -/// Returns the perceptual color distance between two RGB colors. -/// Uses the CIE76 formula (Euclidean distance in Lab space approximation). -pub(crate) fn perceptual_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> f32 { - // Convert sRGB to linear RGB - fn srgb_to_linear(c: u8) -> f32 { - let c = c as f32 / 255.0; - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - - // Convert RGB to XYZ - fn rgb_to_xyz(r: u8, g: u8, b: u8) -> (f32, f32, f32) { - let r = srgb_to_linear(r); - let g = srgb_to_linear(g); - let b = srgb_to_linear(b); - - let x = r * 0.4124 + g * 0.3576 + b * 0.1805; - let y = r * 0.2126 + g * 0.7152 + b * 0.0722; - let z = r * 0.0193 + g * 0.1192 + b * 0.9505; - (x, y, z) - } - - // Convert XYZ to Lab - fn xyz_to_lab(x: f32, y: f32, z: f32) -> (f32, f32, f32) { - // D65 reference white - let xr = x / 0.95047; - let yr = y / 1.00000; - let zr = z / 1.08883; - - fn f(t: f32) -> f32 { - if t > 0.008856 { - t.powf(1.0 / 3.0) - } else { - 7.787 * t + 16.0 / 116.0 - } - } - - let fx = f(xr); - let fy = f(yr); - let fz = f(zr); - - let l = 116.0 * fy - 16.0; - let a = 500.0 * (fx - fy); - let b = 200.0 * (fy - fz); - (l, a, b) - } - - let (x1, y1, z1) = rgb_to_xyz(a.0, a.1, a.2); - let (x2, y2, z2) = rgb_to_xyz(b.0, b.1, b.2); - - let (l1, a1, b1) = xyz_to_lab(x1, y1, z1); - let (l2, a2, b2) = xyz_to_lab(x2, y2, z2); - - let dl = l1 - l2; - let da = a1 - a2; - let db = b1 - b2; - - (dl * dl + da * da + db * db).sqrt() -} diff --git a/codex-rs/tui2/src/custom_terminal.rs b/codex-rs/tui2/src/custom_terminal.rs deleted file mode 100644 index 46d16a83f0..0000000000 --- a/codex-rs/tui2/src/custom_terminal.rs +++ /dev/null @@ -1,645 +0,0 @@ -// This is derived from `ratatui::Terminal`, which is licensed under the following terms: -// -// The MIT License (MIT) -// Copyright (c) 2016-2022 Florian Dehau -// Copyright (c) 2023-2025 The Ratatui Developers -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -use std::io; -use std::io::Write; - -use crossterm::cursor::MoveTo; -use crossterm::queue; -use crossterm::style::Colors; -use crossterm::style::Print; -use crossterm::style::SetAttribute; -use crossterm::style::SetBackgroundColor; -use crossterm::style::SetColors; -use crossterm::style::SetForegroundColor; -use crossterm::terminal::Clear; -use derive_more::IsVariant; -use ratatui::backend::Backend; -use ratatui::backend::ClearType; -use ratatui::buffer::Buffer; -use ratatui::layout::Position; -use ratatui::layout::Rect; -use ratatui::layout::Size; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::widgets::WidgetRef; - -#[derive(Debug, Hash)] -pub struct Frame<'a> { - /// Where should the cursor be after drawing this frame? - /// - /// If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x, - /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. - pub(crate) cursor_position: Option, - - /// The area of the viewport - pub(crate) viewport_area: Rect, - - /// The buffer that is used to draw the current frame - pub(crate) buffer: &'a mut Buffer, -} - -impl Frame<'_> { - /// The area of the current frame - /// - /// This is guaranteed not to change during rendering, so may be called multiple times. - /// - /// If your app listens for a resize event from the backend, it should ignore the values from - /// the event for any calculations that are used to render the current frame and use this value - /// instead as this is the area of the buffer that is used to render the current frame. - pub const fn area(&self) -> Rect { - self.viewport_area - } - - /// Render a [`WidgetRef`] to the current buffer using [`WidgetRef::render_ref`]. - /// - /// Usually the area argument is the size of the current frame or a sub-area of the current - /// frame (which can be obtained using [`Layout`] to split the total area). - #[allow(clippy::needless_pass_by_value)] - pub fn render_widget_ref(&mut self, widget: W, area: Rect) { - widget.render_ref(area, self.buffer); - } - - /// After drawing this frame, make the cursor visible and put it at the specified (x, y) - /// coordinates. If this method is not called, the cursor will be hidden. - /// - /// Note that this will interfere with calls to [`Terminal::hide_cursor`], - /// [`Terminal::show_cursor`], and [`Terminal::set_cursor_position`]. Pick one of the APIs and - /// stick with it. - /// - /// [`Terminal::hide_cursor`]: crate::Terminal::hide_cursor - /// [`Terminal::show_cursor`]: crate::Terminal::show_cursor - /// [`Terminal::set_cursor_position`]: crate::Terminal::set_cursor_position - pub fn set_cursor_position>(&mut self, position: P) { - self.cursor_position = Some(position.into()); - } - - /// Gets the buffer that this `Frame` draws into as a mutable reference. - pub fn buffer_mut(&mut self) -> &mut Buffer { - self.buffer - } -} - -#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct Terminal -where - B: Backend + Write, -{ - /// The backend used to interface with the terminal - backend: B, - /// Holds the results of the current and previous draw calls. The two are compared at the end - /// of each draw pass to output the necessary updates to the terminal - buffers: [Buffer; 2], - /// Index of the current buffer in the previous array - current: usize, - /// Whether the cursor is currently hidden - pub hidden_cursor: bool, - /// Area of the viewport - pub viewport_area: Rect, - /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. - pub last_known_screen_size: Size, - /// Last known position of the cursor. Used to find the new area when the viewport is inlined - /// and the terminal resized. - pub last_known_cursor_pos: Position, -} - -impl Drop for Terminal -where - B: Backend, - B: Write, -{ - #[allow(clippy::print_stderr)] - fn drop(&mut self) { - // Attempt to restore the cursor state - if self.hidden_cursor - && let Err(err) = self.show_cursor() - { - eprintln!("Failed to show the cursor: {err}"); - } - } -} - -impl Terminal -where - B: Backend, - B: Write, -{ - /// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`]. - pub fn with_options(mut backend: B) -> io::Result { - let screen_size = backend.size()?; - let cursor_pos = backend.get_cursor_position()?; - Ok(Self { - backend, - buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], - current: 0, - hidden_cursor: false, - viewport_area: Rect::new(0, cursor_pos.y, 0, 0), - last_known_screen_size: screen_size, - last_known_cursor_pos: cursor_pos, - }) - } - - /// Get a Frame object which provides a consistent view into the terminal state for rendering. - pub fn get_frame(&mut self) -> Frame<'_> { - Frame { - cursor_position: None, - viewport_area: self.viewport_area, - buffer: self.current_buffer_mut(), - } - } - - /// Gets the current buffer as a reference. - fn current_buffer(&self) -> &Buffer { - &self.buffers[self.current] - } - - /// Gets the current buffer as a mutable reference. - fn current_buffer_mut(&mut self) -> &mut Buffer { - &mut self.buffers[self.current] - } - - /// Gets the previous buffer as a reference. - fn previous_buffer(&self) -> &Buffer { - &self.buffers[1 - self.current] - } - - /// Gets the previous buffer as a mutable reference. - fn previous_buffer_mut(&mut self) -> &mut Buffer { - &mut self.buffers[1 - self.current] - } - - /// Gets the backend - pub const fn backend(&self) -> &B { - &self.backend - } - - /// Gets the backend as a mutable reference - pub fn backend_mut(&mut self) -> &mut B { - &mut self.backend - } - - /// Obtains a difference between the previous and the current buffer and passes it to the - /// current backend for drawing. - pub fn flush(&mut self) -> io::Result<()> { - let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); - let last_put_command = updates.iter().rfind(|command| command.is_put()); - if let Some(&DrawCommand::Put { x, y, .. }) = last_put_command { - self.last_known_cursor_pos = Position { x, y }; - } - draw(&mut self.backend, updates.into_iter()) - } - - /// Updates the Terminal so that internal buffers match the requested area. - /// - /// Requested area will be saved to remain consistent when rendering. This leads to a full clear - /// of the screen. - pub fn resize(&mut self, screen_size: Size) -> io::Result<()> { - self.last_known_screen_size = screen_size; - Ok(()) - } - - /// Sets the viewport area. - pub fn set_viewport_area(&mut self, area: Rect) { - self.current_buffer_mut().resize(area); - self.previous_buffer_mut().resize(area); - self.viewport_area = area; - } - - /// Queries the backend for size and resizes if it doesn't match the previous size. - pub fn autoresize(&mut self) -> io::Result<()> { - let screen_size = self.size()?; - if screen_size != self.last_known_screen_size { - self.resize(screen_size)?; - } - Ok(()) - } - - /// Draws a single frame to the terminal. - /// - /// Returns a [`CompletedFrame`] if successful, otherwise a [`std::io::Error`]. - /// - /// If the render callback passed to this method can fail, use [`try_draw`] instead. - /// - /// Applications should call `draw` or [`try_draw`] in a loop to continuously render the - /// terminal. These methods are the main entry points for drawing to the terminal. - /// - /// [`try_draw`]: Terminal::try_draw - /// - /// This method will: - /// - /// - autoresize the terminal if necessary - /// - call the render callback, passing it a [`Frame`] reference to render to - /// - flush the current internal state by copying the current buffer to the backend - /// - move the cursor to the last known position if it was set during the rendering closure - /// - /// The render callback should fully render the entire frame when called, including areas that - /// are unchanged from the previous frame. This is because each frame is compared to the - /// previous frame to determine what has changed, and only the changes are written to the - /// terminal. If the render callback does not fully render the frame, the terminal will not be - /// in a consistent state. - pub fn draw(&mut self, render_callback: F) -> io::Result<()> - where - F: FnOnce(&mut Frame), - { - self.try_draw(|frame| { - render_callback(frame); - io::Result::Ok(()) - }) - } - - /// Tries to draw a single frame to the terminal. - /// - /// Returns [`Result::Ok`] containing a [`CompletedFrame`] if successful, otherwise - /// [`Result::Err`] containing the [`std::io::Error`] that caused the failure. - /// - /// This is the equivalent of [`Terminal::draw`] but the render callback is a function or - /// closure that returns a `Result` instead of nothing. - /// - /// Applications should call `try_draw` or [`draw`] in a loop to continuously render the - /// terminal. These methods are the main entry points for drawing to the terminal. - /// - /// [`draw`]: Terminal::draw - /// - /// This method will: - /// - /// - autoresize the terminal if necessary - /// - call the render callback, passing it a [`Frame`] reference to render to - /// - flush the current internal state by copying the current buffer to the backend - /// - move the cursor to the last known position if it was set during the rendering closure - /// - return a [`CompletedFrame`] with the current buffer and the area of the terminal - /// - /// The render callback passed to `try_draw` can return any [`Result`] with an error type that - /// can be converted into an [`std::io::Error`] using the [`Into`] trait. This makes it possible - /// to use the `?` operator to propagate errors that occur during rendering. If the render - /// callback returns an error, the error will be returned from `try_draw` as an - /// [`std::io::Error`] and the terminal will not be updated. - /// - /// The [`CompletedFrame`] returned by this method can be useful for debugging or testing - /// purposes, but it is often not used in regular applicationss. - /// - /// The render callback should fully render the entire frame when called, including areas that - /// are unchanged from the previous frame. This is because each frame is compared to the - /// previous frame to determine what has changed, and only the changes are written to the - /// terminal. If the render function does not fully render the frame, the terminal will not be - /// in a consistent state. - pub fn try_draw(&mut self, render_callback: F) -> io::Result<()> - where - F: FnOnce(&mut Frame) -> Result<(), E>, - E: Into, - { - // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets - // and the terminal (if growing), which may OOB. - self.autoresize()?; - - let mut frame = self.get_frame(); - - render_callback(&mut frame).map_err(Into::into)?; - - // We can't change the cursor position right away because we have to flush the frame to - // stdout first. But we also can't keep the frame around, since it holds a &mut to - // Buffer. Thus, we're taking the important data out of the Frame and dropping it. - let cursor_position = frame.cursor_position; - - // Draw to stdout - self.flush()?; - - match cursor_position { - None => self.hide_cursor()?, - Some(position) => { - self.show_cursor()?; - self.set_cursor_position(position)?; - } - } - - self.swap_buffers(); - - Backend::flush(&mut self.backend)?; - - Ok(()) - } - - /// Hides the cursor. - pub fn hide_cursor(&mut self) -> io::Result<()> { - self.backend.hide_cursor()?; - self.hidden_cursor = true; - Ok(()) - } - - /// Shows the cursor. - pub fn show_cursor(&mut self) -> io::Result<()> { - self.backend.show_cursor()?; - self.hidden_cursor = false; - Ok(()) - } - - /// Gets the current cursor position. - /// - /// This is the position of the cursor after the last draw call. - #[allow(dead_code)] - pub fn get_cursor_position(&mut self) -> io::Result { - self.backend.get_cursor_position() - } - - /// Sets the cursor position. - pub fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { - let position = position.into(); - self.backend.set_cursor_position(position)?; - self.last_known_cursor_pos = position; - Ok(()) - } - - /// Clear the terminal and force a full redraw on the next draw call. - pub fn clear(&mut self) -> io::Result<()> { - if self.viewport_area.is_empty() { - return Ok(()); - } - self.backend - .set_cursor_position(self.viewport_area.as_position())?; - self.backend.clear_region(ClearType::AfterCursor)?; - // Reset the back buffer to make sure the next update will redraw everything. - self.previous_buffer_mut().reset(); - Ok(()) - } - - /// Clears the inactive buffer and swaps it with the current buffer - pub fn swap_buffers(&mut self) { - self.previous_buffer_mut().reset(); - self.current = 1 - self.current; - } - - /// Queries the real size of the backend. - pub fn size(&self) -> io::Result { - self.backend.size() - } -} - -use ratatui::buffer::Cell; -use unicode_width::UnicodeWidthStr; - -#[derive(Debug, IsVariant)] -enum DrawCommand { - Put { x: u16, y: u16, cell: Cell }, - ClearToEnd { x: u16, y: u16, bg: Color }, -} - -fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { - let previous_buffer = &a.content; - let next_buffer = &b.content; - - let mut updates = vec![]; - let mut last_nonblank_columns = vec![0; a.area.height as usize]; - for y in 0..a.area.height { - let row_start = y as usize * a.area.width as usize; - let row_end = row_start + a.area.width as usize; - let row = &next_buffer[row_start..row_end]; - let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); - - // Scan the row to find the rightmost column that still matters: any non-space glyph, - // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. - // Multi-width glyphs extend that region through their full displayed width. - // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win - // versus emitting multiple space Put commands. - let mut last_nonblank_column = 0usize; - let mut column = 0usize; - while column < row.len() { - let cell = &row[column]; - let width = cell.symbol().width(); - if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { - last_nonblank_column = column + (width.saturating_sub(1)); - } - column += width.max(1); // treat zero-width symbols as width 1 - } - - if last_nonblank_column + 1 < row.len() { - let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); - updates.push(DrawCommand::ClearToEnd { x, y, bg }); - } - - last_nonblank_columns[y as usize] = last_nonblank_column as u16; - } - - // Cells invalidated by drawing/replacing preceding multi-width characters: - let mut invalidated: usize = 0; - // Cells from the current buffer to skip due to preceding multi-width characters taking - // their place (the skipped cells should be blank anyway), or due to per-cell-skipping: - let mut to_skip: usize = 0; - for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { - if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { - let (x, y) = a.pos_of(i); - let row = i / a.area.width as usize; - if x <= last_nonblank_columns[row] { - updates.push(DrawCommand::Put { - x, - y, - cell: next_buffer[i].clone(), - }); - } - } - - to_skip = current.symbol().width().saturating_sub(1); - - let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width()); - invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); - } - updates -} - -fn draw(writer: &mut impl Write, commands: I) -> io::Result<()> -where - I: Iterator, -{ - let mut fg = Color::Reset; - let mut bg = Color::Reset; - let mut modifier = Modifier::empty(); - let mut last_pos: Option = None; - for command in commands { - let (x, y) = match command { - DrawCommand::Put { x, y, .. } => (x, y), - DrawCommand::ClearToEnd { x, y, .. } => (x, y), - }; - // Move the cursor if the previous location was not (x - 1, y) - if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { - queue!(writer, MoveTo(x, y))?; - } - last_pos = Some(Position { x, y }); - match command { - DrawCommand::Put { cell, .. } => { - if cell.modifier != modifier { - let diff = ModifierDiff { - from: modifier, - to: cell.modifier, - }; - diff.queue(writer)?; - modifier = cell.modifier; - } - if cell.fg != fg || cell.bg != bg { - queue!( - writer, - SetColors(Colors::new(cell.fg.into(), cell.bg.into())) - )?; - fg = cell.fg; - bg = cell.bg; - } - - queue!(writer, Print(cell.symbol()))?; - } - DrawCommand::ClearToEnd { bg: clear_bg, .. } => { - queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; - modifier = Modifier::empty(); - queue!(writer, SetBackgroundColor(clear_bg.into()))?; - bg = clear_bg; - queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; - } - } - } - - queue!( - writer, - SetForegroundColor(crossterm::style::Color::Reset), - SetBackgroundColor(crossterm::style::Color::Reset), - SetAttribute(crossterm::style::Attribute::Reset), - )?; - - Ok(()) -} - -/// The `ModifierDiff` struct is used to calculate the difference between two `Modifier` -/// values. This is useful when updating the terminal display, as it allows for more -/// efficient updates by only sending the necessary changes. -struct ModifierDiff { - pub from: Modifier, - pub to: Modifier, -} - -impl ModifierDiff { - fn queue(self, w: &mut W) -> io::Result<()> { - use crossterm::style::Attribute as CAttribute; - let removed = self.from - self.to; - if removed.contains(Modifier::REVERSED) { - queue!(w, SetAttribute(CAttribute::NoReverse))?; - } - if removed.contains(Modifier::BOLD) { - queue!(w, SetAttribute(CAttribute::NormalIntensity))?; - if self.to.contains(Modifier::DIM) { - queue!(w, SetAttribute(CAttribute::Dim))?; - } - } - if removed.contains(Modifier::ITALIC) { - queue!(w, SetAttribute(CAttribute::NoItalic))?; - } - if removed.contains(Modifier::UNDERLINED) { - queue!(w, SetAttribute(CAttribute::NoUnderline))?; - } - if removed.contains(Modifier::DIM) { - queue!(w, SetAttribute(CAttribute::NormalIntensity))?; - } - if removed.contains(Modifier::CROSSED_OUT) { - queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; - } - if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { - queue!(w, SetAttribute(CAttribute::NoBlink))?; - } - - let added = self.to - self.from; - if added.contains(Modifier::REVERSED) { - queue!(w, SetAttribute(CAttribute::Reverse))?; - } - if added.contains(Modifier::BOLD) { - queue!(w, SetAttribute(CAttribute::Bold))?; - } - if added.contains(Modifier::ITALIC) { - queue!(w, SetAttribute(CAttribute::Italic))?; - } - if added.contains(Modifier::UNDERLINED) { - queue!(w, SetAttribute(CAttribute::Underlined))?; - } - if added.contains(Modifier::DIM) { - queue!(w, SetAttribute(CAttribute::Dim))?; - } - if added.contains(Modifier::CROSSED_OUT) { - queue!(w, SetAttribute(CAttribute::CrossedOut))?; - } - if added.contains(Modifier::SLOW_BLINK) { - queue!(w, SetAttribute(CAttribute::SlowBlink))?; - } - if added.contains(Modifier::RAPID_BLINK) { - queue!(w, SetAttribute(CAttribute::RapidBlink))?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use ratatui::layout::Rect; - use ratatui::style::Style; - - #[test] - fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { - let area = Rect::new(0, 0, 3, 2); - let previous = Buffer::empty(area); - let mut next = Buffer::empty(area); - - next.cell_mut((2, 0)) - .expect("cell should exist") - .set_symbol("X"); - - let commands = diff_buffers(&previous, &next); - - let clear_count = commands - .iter() - .filter(|command| matches!(command, DrawCommand::ClearToEnd { y, .. } if *y == 0)) - .count(); - assert_eq!( - 0, clear_count, - "expected diff_buffers not to emit ClearToEnd; commands: {commands:?}", - ); - assert!( - commands - .iter() - .any(|command| matches!(command, DrawCommand::Put { x: 2, y: 0, .. })), - "expected diff_buffers to update the final cell; commands: {commands:?}", - ); - } - - #[test] - fn diff_buffers_clear_to_end_starts_after_wide_char() { - let area = Rect::new(0, 0, 10, 1); - let mut previous = Buffer::empty(area); - let mut next = Buffer::empty(area); - - previous.set_string(0, 0, "中文", Style::default()); - next.set_string(0, 0, "中", Style::default()); - - let commands = diff_buffers(&previous, &next); - assert!( - commands - .iter() - .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), - "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" - ); - } -} diff --git a/codex-rs/tui2/src/diff_render.rs b/codex-rs/tui2/src/diff_render.rs deleted file mode 100644 index b92f24349a..0000000000 --- a/codex-rs/tui2/src/diff_render.rs +++ /dev/null @@ -1,708 +0,0 @@ -use diffy::Hunk; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -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 ratatui::widgets::Paragraph; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; - -use crate::exec_command::relativize_to_home; -use crate::render::Insets; -use crate::render::line_utils::prefix_lines; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::InsetRenderable; -use crate::render::renderable::Renderable; -use codex_core::git_info::get_git_repo_root; -use codex_core::protocol::FileChange; - -// Internal representation for diff line rendering -enum DiffLineType { - Insert, - Delete, - Context, -} - -pub struct DiffSummary { - changes: HashMap, - cwd: PathBuf, -} - -impl DiffSummary { - pub fn new(changes: HashMap, cwd: PathBuf) -> Self { - Self { changes, cwd } - } -} - -impl Renderable for FileChange { - fn render(&self, area: Rect, buf: &mut Buffer) { - let mut lines = vec![]; - render_change(self, &mut lines, area.width as usize); - Paragraph::new(lines).render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - let mut lines = vec![]; - render_change(self, &mut lines, width as usize); - lines.len() as u16 - } -} - -impl From for Box { - fn from(val: DiffSummary) -> Self { - let mut rows: Vec> = vec![]; - - for (i, row) in collect_rows(&val.changes).into_iter().enumerate() { - if i > 0 { - rows.push(Box::new(RtLine::from(""))); - } - let mut path = RtLine::from(display_path_for(&row.path, &val.cwd)); - path.push_span(" "); - path.extend(render_line_count_summary(row.added, row.removed)); - rows.push(Box::new(path)); - rows.push(Box::new(RtLine::from(""))); - rows.push(Box::new(InsetRenderable::new( - Box::new(row.change) as Box, - Insets::tlbr(0, 2, 0, 0), - ))); - } - - Box::new(ColumnRenderable::with(rows)) - } -} - -pub(crate) fn create_diff_summary( - changes: &HashMap, - cwd: &Path, - wrap_cols: usize, -) -> Vec> { - let rows = collect_rows(changes); - render_changes_block(rows, wrap_cols, cwd) -} - -// Shared row for per-file presentation -#[derive(Clone)] -struct Row { - #[allow(dead_code)] - path: PathBuf, - move_path: Option, - added: usize, - removed: usize, - change: FileChange, -} - -fn collect_rows(changes: &HashMap) -> Vec { - let mut rows: Vec = 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 -} - -fn render_line_count_summary(added: usize, removed: usize) -> Vec> { - 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 -} - -fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec> { - let mut out: Vec> = Vec::new(); - - let render_path = |row: &Row| -> Vec> { - 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()); - } - 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> = vec!["• ".dim()]; - 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)); - } - out.push(RtLine::from(header_spans)); - - 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 = file_count == 1; - if !skip_file_header { - let mut header: Vec> = 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)); - } - - let mut lines = vec![]; - render_change(&r.change, &mut lines, wrap_cols - 4); - out.extend(prefix_lines(lines, " ".into(), " ".into())); - } - - out -} - -fn render_change(change: &FileChange, out: &mut Vec>, width: usize) { - match change { - FileChange::Add { content } => { - let line_number_width = line_number_width(content.lines().count()); - for (i, raw) in content.lines().enumerate() { - out.extend(push_wrapped_diff_line( - i + 1, - DiffLineType::Insert, - raw, - width, - line_number_width, - )); - } - } - FileChange::Delete { content } => { - let line_number_width = line_number_width(content.lines().count()); - for (i, raw) in content.lines().enumerate() { - out.extend(push_wrapped_diff_line( - i + 1, - DiffLineType::Delete, - raw, - width, - line_number_width, - )); - } - } - FileChange::Update { unified_diff, .. } => { - if let Ok(patch) = diffy::Patch::from_str(unified_diff) { - let mut max_line_number = 0; - for h in patch.hunks() { - let mut old_ln = h.old_range().start(); - let mut new_ln = h.new_range().start(); - for l in h.lines() { - match l { - diffy::Line::Insert(_) => { - max_line_number = max_line_number.max(new_ln); - new_ln += 1; - } - diffy::Line::Delete(_) => { - max_line_number = max_line_number.max(old_ln); - old_ln += 1; - } - diffy::Line::Context(_) => { - max_line_number = max_line_number.max(new_ln); - old_ln += 1; - new_ln += 1; - } - } - } - } - let line_number_width = line_number_width(max_line_number); - let mut is_first_hunk = true; - for h in patch.hunks() { - if !is_first_hunk { - let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); - let spacer_span = RtSpan::styled(spacer, style_gutter()); - out.push(RtLine::from(vec![spacer_span, "⋮".dim()])); - } - is_first_hunk = false; - - let mut old_ln = h.old_range().start(); - let mut new_ln = h.new_range().start(); - for l in h.lines() { - match l { - diffy::Line::Insert(text) => { - let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Insert, - s, - width, - line_number_width, - )); - new_ln += 1; - } - diffy::Line::Delete(text) => { - let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - old_ln, - DiffLineType::Delete, - s, - width, - line_number_width, - )); - old_ln += 1; - } - diffy::Line::Context(text) => { - let s = text.trim_end_matches('\n'); - out.extend(push_wrapped_diff_line( - new_ln, - DiffLineType::Context, - s, - width, - line_number_width, - )); - old_ln += 1; - new_ln += 1; - } - } - } - } - } - } - } -} - -/// Format a path for display relative to the current working directory when -/// possible, keeping output stable in jj/no-`.git` workspaces (e.g. image -/// tool calls should show `example.png` instead of an absolute path). -pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { - if path.is_relative() { - return path.display().to_string(); - } - - if let Ok(stripped) = path.strip_prefix(cwd) { - return stripped.display().to_string(); - } - - // Prefer a stable, user-local relative path when the file is under the current working - // directory. This keeps output deterministic in jj-only repos (no `.git`) and matches user - // expectations for "files in this project". - if let Some(rel) = pathdiff::diff_paths(path, cwd) - && !rel - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - return rel.display().to_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) - .map(|p| PathBuf::from_iter([Path::new("~"), p.as_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(Hunk::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, - text: &str, - width: usize, - line_number_width: usize, -) -> Vec> { - let ln_str = line_number.to_string(); - let mut remaining_text: &str = text; - - // Reserve a fixed number of spaces (equal to the widest line number plus a - // trailing spacer) so the sign column stays aligned across the diff block. - let gutter_width = line_number_width.max(1); - let prefix_cols = gutter_width + 1; - - let mut first = true; - let (sign_char, line_style) = match kind { - DiffLineType::Insert => ('+', style_add()), - DiffLineType::Delete => ('-', style_del()), - DiffLineType::Context => (' ', style_context()), - }; - let mut lines: Vec> = Vec::new(); - - loop { - // 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 = width.saturating_sub(prefix_cols + 1).max(1); - let split_at_byte_index = remaining_text - .char_indices() - .nth(available_content_cols) - .map(|(i, _)| i) - .unwrap_or_else(|| remaining_text.len()); - let (chunk, rest) = remaining_text.split_at(split_at_byte_index); - remaining_text = rest; - - if first { - // Build gutter (right-aligned line number plus spacer) as a dimmed span - let gutter = format!("{ln_str:>gutter_width$} "); - // 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 gutter = format!("{:gutter_width$} ", ""); - lines.push(RtLine::from(vec![ - RtSpan::styled(gutter, style_gutter()), - RtSpan::styled(chunk.to_string(), line_style), - ])); - } - if remaining_text.is_empty() { - break; - } - } - lines -} - -fn line_number_width(max_line_number: usize) -> usize { - if max_line_number == 0 { - 1 - } else { - max_line_number.to_string().len() - } -} - -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) -} - -fn style_del() -> Style { - Style::default().fg(Color::Red) -} - -#[cfg(test)] -mod tests { - use super::*; - use insta::assert_snapshot; - use pretty_assertions::assert_eq; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use ratatui::text::Text; - use ratatui::widgets::Paragraph; - use ratatui::widgets::WidgetRef; - use ratatui::widgets::Wrap; - fn diff_summary_for_tests(changes: &HashMap) -> Vec> { - create_diff_summary(changes, &PathBuf::from("/"), 80) - } - - fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { - let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); - terminal - .draw(|f| { - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .render_ref(f.area(), f.buffer_mut()) - }) - .expect("draw"); - 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::() - }) - .map(|s| s.trim_end().to_string()) - .collect::>() - .join("\n"); - assert_snapshot!(name, text); - } - - #[test] - fn display_path_prefers_cwd_without_git_repo() { - let cwd = if cfg!(windows) { - PathBuf::from(r"C:\workspace\codex") - } else { - PathBuf::from("/workspace/codex") - }; - let path = cwd.join("tui").join("example.png"); - - let rendered = display_path_for(&path, &cwd); - - assert_eq!( - rendered, - PathBuf::from("tui") - .join("example.png") - .display() - .to_string() - ); - } - - #[test] - fn ui_snapshot_wrap_behavior_insert() { - // Narrow width to force wrapping within our diff line rendering - let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; - - // Call the wrapping function directly so we can precisely control the width - let lines = - push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1)); - - // Render into a small terminal to capture the visual layout - snapshot_lines("wrap_behavior_insert", lines, 90, 8); - } - - #[test] - fn ui_snapshot_apply_update_block() { - let mut changes: HashMap = HashMap::new(); - let original = "line one\nline two\nline three\n"; - let modified = "line one\nline two changed\nline three\n"; - let patch = diffy::create_patch(original, modified).to_string(); - - changes.insert( - PathBuf::from("example.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = diff_summary_for_tests(&changes); - - snapshot_lines("apply_update_block", lines, 80, 12); - } - - #[test] - fn ui_snapshot_apply_update_with_rename_block() { - let mut changes: HashMap = 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); - - 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 = 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); - - snapshot_lines("apply_multiple_files_block", lines, 80, 14); - } - - #[test] - fn ui_snapshot_apply_add_block() { - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("new_file.txt"), - FileChange::Add { - content: "alpha\nbeta\n".to_string(), - }, - ); - - let lines = diff_summary_for_tests(&changes); - - snapshot_lines("apply_add_block", lines, 80, 10); - } - - #[test] - fn ui_snapshot_apply_delete_block() { - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("tmp_delete_example.txt"), - FileChange::Delete { - content: "first\nsecond\nthird\n".to_string(), - }, - ); - - let lines = diff_summary_for_tests(&changes); - 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 = HashMap::new(); - changes.insert( - PathBuf::from("long_example.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &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 = HashMap::new(); - changes.insert( - PathBuf::from("wrap_demo.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); - snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); - } - - #[test] - fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() { - let original = (1..=110).map(|i| format!("line {i}\n")).collect::(); - let modified = (1..=110) - .map(|i| { - if i == 100 { - format!("line {i} changed\n") - } else { - format!("line {i}\n") - } - }) - .collect::(); - let patch = diffy::create_patch(&original, &modified).to_string(); - - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("hundreds.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); - snapshot_lines_text("apply_update_block_line_numbers_three_digits_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 = HashMap::new(); - changes.insert( - abs_old, - FileChange::Update { - unified_diff: patch, - move_path: Some(abs_new), - }, - ); - - let lines = create_diff_summary(&changes, &cwd, 80); - - snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); - } -} diff --git a/codex-rs/tui2/src/exec_cell/mod.rs b/codex-rs/tui2/src/exec_cell/mod.rs deleted file mode 100644 index 906091113e..0000000000 --- a/codex-rs/tui2/src/exec_cell/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -mod model; -mod render; - -pub(crate) use model::CommandOutput; -#[cfg(test)] -pub(crate) use model::ExecCall; -pub(crate) use model::ExecCell; -pub(crate) use render::OutputLinesParams; -pub(crate) use render::TOOL_CALL_MAX_LINES; -pub(crate) use render::new_active_exec_command; -pub(crate) use render::output_lines; -pub(crate) use render::spinner; diff --git a/codex-rs/tui2/src/exec_cell/model.rs b/codex-rs/tui2/src/exec_cell/model.rs deleted file mode 100644 index 21799c6199..0000000000 --- a/codex-rs/tui2/src/exec_cell/model.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::time::Duration; -use std::time::Instant; - -use codex_core::protocol::ExecCommandSource; -use codex_protocol::parse_command::ParsedCommand; - -#[derive(Clone, Debug, Default)] -pub(crate) struct CommandOutput { - pub(crate) exit_code: i32, - /// The aggregated stderr + stdout interleaved. - pub(crate) aggregated_output: String, - /// The formatted output of the command, as seen by the model. - pub(crate) formatted_output: String, -} - -#[derive(Debug, Clone)] -pub(crate) struct ExecCall { - pub(crate) call_id: String, - pub(crate) command: Vec, - pub(crate) parsed: Vec, - pub(crate) output: Option, - pub(crate) source: ExecCommandSource, - pub(crate) start_time: Option, - pub(crate) duration: Option, - pub(crate) interaction_input: Option, -} - -#[derive(Debug)] -pub(crate) struct ExecCell { - pub(crate) calls: Vec, - animations_enabled: bool, -} - -impl ExecCell { - pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self { - Self { - calls: vec![call], - animations_enabled, - } - } - - pub(crate) fn with_added_call( - &self, - call_id: String, - command: Vec, - parsed: Vec, - source: ExecCommandSource, - interaction_input: Option, - ) -> Option { - let call = ExecCall { - call_id, - command, - parsed, - output: None, - source, - start_time: Some(Instant::now()), - duration: None, - interaction_input, - }; - if self.is_exploring_cell() && Self::is_exploring_call(&call) { - Some(Self { - calls: [self.calls.clone(), vec![call]].concat(), - animations_enabled: self.animations_enabled, - }) - } else { - None - } - } - - pub(crate) fn complete_call( - &mut self, - call_id: &str, - output: CommandOutput, - duration: Duration, - ) { - if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) { - call.output = Some(output); - call.duration = Some(duration); - call.start_time = None; - } - } - - pub(crate) fn should_flush(&self) -> bool { - !self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some()) - } - - pub(crate) fn mark_failed(&mut self) { - for call in self.calls.iter_mut() { - if call.output.is_none() { - let elapsed = call - .start_time - .map(|st| st.elapsed()) - .unwrap_or_else(|| Duration::from_millis(0)); - call.start_time = None; - call.duration = Some(elapsed); - call.output = Some(CommandOutput { - exit_code: 1, - formatted_output: String::new(), - aggregated_output: String::new(), - }); - } - } - } - - pub(crate) fn is_exploring_cell(&self) -> bool { - self.calls.iter().all(Self::is_exploring_call) - } - - pub(crate) fn is_active(&self) -> bool { - self.calls.iter().any(|c| c.output.is_none()) - } - - pub(crate) fn active_start_time(&self) -> Option { - self.calls - .iter() - .find(|c| c.output.is_none()) - .and_then(|c| c.start_time) - } - - pub(crate) fn animations_enabled(&self) -> bool { - self.animations_enabled - } - - pub(crate) fn iter_calls(&self) -> impl Iterator { - self.calls.iter() - } - - pub(crate) fn append_output(&mut self, call_id: &str, chunk: &str) -> bool { - if chunk.is_empty() { - return false; - } - let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) else { - return false; - }; - let output = call.output.get_or_insert_with(CommandOutput::default); - output.aggregated_output.push_str(chunk); - true - } - - pub(super) fn is_exploring_call(call: &ExecCall) -> bool { - !matches!(call.source, ExecCommandSource::UserShell) - && !call.parsed.is_empty() - && call.parsed.iter().all(|p| { - matches!( - p, - ParsedCommand::Read { .. } - | ParsedCommand::ListFiles { .. } - | ParsedCommand::Search { .. } - ) - }) - } -} - -impl ExecCall { - pub(crate) fn is_user_shell_command(&self) -> bool { - matches!(self.source, ExecCommandSource::UserShell) - } - - pub(crate) fn is_unified_exec_interaction(&self) -> bool { - matches!(self.source, ExecCommandSource::UnifiedExecInteraction) - } -} diff --git a/codex-rs/tui2/src/exec_cell/render.rs b/codex-rs/tui2/src/exec_cell/render.rs deleted file mode 100644 index 6517bcf470..0000000000 --- a/codex-rs/tui2/src/exec_cell/render.rs +++ /dev/null @@ -1,705 +0,0 @@ -use std::time::Instant; - -use super::model::CommandOutput; -use super::model::ExecCall; -use super::model::ExecCell; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::history_cell::HistoryCell; -use crate::render::highlight::highlight_bash_to_lines; -use crate::render::line_utils::prefix_lines; -use crate::render::line_utils::push_owned_lines; -use crate::shimmer::shimmer_spans; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; -use codex_ansi_escape::ansi_escape_line; -use codex_common::elapsed::format_duration; -use codex_core::bash::extract_bash_command; -use codex_core::protocol::ExecCommandSource; -use codex_protocol::parse_command::ParsedCommand; -use itertools::Itertools; -use ratatui::prelude::*; -use ratatui::style::Modifier; -use ratatui::style::Stylize; -use textwrap::WordSplitter; -use unicode_width::UnicodeWidthStr; - -pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; -const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; -const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; - -pub(crate) struct OutputLinesParams { - pub(crate) line_limit: usize, - pub(crate) only_err: bool, - pub(crate) include_angle_pipe: bool, - pub(crate) include_prefix: bool, -} - -pub(crate) fn new_active_exec_command( - call_id: String, - command: Vec, - parsed: Vec, - source: ExecCommandSource, - interaction_input: Option, - animations_enabled: bool, -) -> ExecCell { - ExecCell::new( - ExecCall { - call_id, - command, - parsed, - output: None, - source, - start_time: Some(Instant::now()), - duration: None, - interaction_input, - }, - animations_enabled, - ) -} - -fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String { - let command_display = if let Some((_, script)) = extract_bash_command(command) { - script.to_string() - } else { - command.join(" ") - }; - match input { - Some(data) if !data.is_empty() => { - let preview = summarize_interaction_input(data); - format!("Interacted with `{command_display}`, sent `{preview}`") - } - _ => format!("Waited for `{command_display}`"), - } -} - -fn summarize_interaction_input(input: &str) -> String { - let single_line = input.replace('\n', "\\n"); - let sanitized = single_line.replace('`', "\\`"); - if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS { - return sanitized; - } - - let mut preview = String::new(); - for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) { - preview.push(ch); - } - preview.push_str("..."); - preview -} - -#[derive(Clone)] -pub(crate) struct OutputLines { - pub(crate) lines: Vec>, - pub(crate) omitted: Option, -} - -pub(crate) fn output_lines( - output: Option<&CommandOutput>, - params: OutputLinesParams, -) -> OutputLines { - let OutputLinesParams { - line_limit, - only_err, - include_angle_pipe, - include_prefix, - } = params; - let CommandOutput { - aggregated_output, .. - } = match output { - Some(output) if only_err && output.exit_code == 0 => { - return OutputLines { - lines: Vec::new(), - omitted: None, - }; - } - Some(output) => output, - None => { - return OutputLines { - lines: Vec::new(), - omitted: None, - }; - } - }; - - let src = aggregated_output; - let lines: Vec<&str> = src.lines().collect(); - let total = lines.len(); - let mut out: Vec> = Vec::new(); - - let head_end = total.min(line_limit); - for (i, raw) in lines[..head_end].iter().enumerate() { - let mut line = ansi_escape_line(raw); - let prefix = if !include_prefix { - "" - } else if i == 0 && include_angle_pipe { - " └ " - } else { - " " - }; - line.spans.insert(0, prefix.into()); - line.spans.iter_mut().for_each(|span| { - span.style = span.style.add_modifier(Modifier::DIM); - }); - out.push(line); - } - - let show_ellipsis = total > 2 * line_limit; - let omitted = if show_ellipsis { - Some(total - 2 * line_limit) - } else { - None - }; - if show_ellipsis { - let omitted = total - 2 * line_limit; - out.push(format!("… +{omitted} lines").into()); - } - - let tail_start = if show_ellipsis { - total - line_limit - } else { - head_end - }; - for raw in lines[tail_start..].iter() { - let mut line = ansi_escape_line(raw); - if include_prefix { - line.spans.insert(0, " ".into()); - } - line.spans.iter_mut().for_each(|span| { - span.style = span.style.add_modifier(Modifier::DIM); - }); - out.push(line); - } - - OutputLines { - lines: out, - omitted, - } -} - -pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> Span<'static> { - if !animations_enabled { - return "•".dim(); - } - let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); - if supports_color::on_cached(supports_color::Stream::Stdout) - .map(|level| level.has_16m) - .unwrap_or(false) - { - shimmer_spans("•")[0].clone() - } else { - let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); - if blink_on { "•".into() } else { "◦".dim() } - } -} - -impl HistoryCell for ExecCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.is_exploring_cell() { - self.exploring_display_lines(width) - } else { - self.command_display_lines(width) - } - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.transcript_lines(width).len() as u16 - } - - fn transcript_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = vec![]; - for (i, call) in self.iter_calls().enumerate() { - if i > 0 { - lines.push("".into()); - } - let script = strip_bash_lc_and_escape(&call.command); - let highlighted_script = highlight_bash_to_lines(&script); - let cmd_display = word_wrap_lines( - &highlighted_script, - RtOptions::new(width as usize) - .initial_indent("$ ".magenta().into()) - .subsequent_indent(" ".into()), - ); - lines.extend(cmd_display); - - if let Some(output) = call.output.as_ref() { - if !call.is_unified_exec_interaction() { - let wrap_width = width.max(1) as usize; - let wrap_opts = RtOptions::new(wrap_width); - for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { - let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); - push_owned_lines(&wrapped, &mut lines); - } - } - let duration = call - .duration - .map(format_duration) - .unwrap_or_else(|| "unknown".to_string()); - let mut result: Line = if output.exit_code == 0 { - Line::from("✓".green().bold()) - } else { - Line::from(vec![ - "✗".red().bold(), - format!(" ({})", output.exit_code).into(), - ]) - }; - result.push_span(format!(" • {duration}").dim()); - lines.push(result); - } - } - lines - } -} - -impl ExecCell { - fn exploring_display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - out.push(Line::from(vec![ - if self.is_active() { - spinner(self.active_start_time(), self.animations_enabled()) - } else { - "•".dim() - }, - " ".into(), - if self.is_active() { - "Exploring".bold() - } else { - "Explored".bold() - }, - ])); - - let mut calls = self.calls.clone(); - let mut out_indented = Vec::new(); - while !calls.is_empty() { - let mut call = calls.remove(0); - if call - .parsed - .iter() - .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) - { - while let Some(next) = calls.first() { - if next - .parsed - .iter() - .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })) - { - call.parsed.extend(next.parsed.clone()); - calls.remove(0); - } else { - break; - } - } - } - - let reads_only = call - .parsed - .iter() - .all(|parsed| matches!(parsed, ParsedCommand::Read { .. })); - - let call_lines: Vec<(&str, Vec>)> = if reads_only { - let names = call - .parsed - .iter() - .map(|parsed| match parsed { - ParsedCommand::Read { name, .. } => name.clone(), - _ => unreachable!(), - }) - .unique(); - vec![( - "Read", - Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(), - )] - } else { - let mut lines = Vec::new(); - for parsed in &call.parsed { - match parsed { - ParsedCommand::Read { name, .. } => { - lines.push(("Read", vec![name.clone().into()])); - } - ParsedCommand::ListFiles { cmd, path } => { - lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()])); - } - ParsedCommand::Search { cmd, query, path } => { - let spans = match (query, path) { - (Some(q), Some(p)) => { - vec![q.clone().into(), " in ".dim(), p.clone().into()] - } - (Some(q), None) => vec![q.clone().into()], - _ => vec![cmd.clone().into()], - }; - lines.push(("Search", spans)); - } - ParsedCommand::Unknown { cmd } => { - lines.push(("Run", vec![cmd.clone().into()])); - } - } - } - lines - }; - - for (title, line) in call_lines { - let line = Line::from(line); - let initial_indent = Line::from(vec![title.cyan(), " ".into()]); - let subsequent_indent = " ".repeat(initial_indent.width()).into(); - let wrapped = word_wrap_line( - &line, - RtOptions::new(width as usize) - .initial_indent(initial_indent) - .subsequent_indent(subsequent_indent), - ); - push_owned_lines(&wrapped, &mut out_indented); - } - } - - out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into())); - out - } - - fn command_display_lines(&self, width: u16) -> Vec> { - let [call] = &self.calls.as_slice() else { - panic!("Expected exactly one call in a command display cell"); - }; - let layout = EXEC_DISPLAY_LAYOUT; - let success = call.output.as_ref().map(|o| o.exit_code == 0); - let bullet = match success { - Some(true) => "•".green().bold(), - Some(false) => "•".red().bold(), - None => spinner(call.start_time, self.animations_enabled()), - }; - let is_interaction = call.is_unified_exec_interaction(); - let title = if is_interaction { - "" - } else if self.is_active() { - "Running" - } else if call.is_user_shell_command() { - "You ran" - } else { - "Ran" - }; - - let mut header_line = if is_interaction { - Line::from(vec![bullet.clone(), " ".into()]) - } else { - Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]) - }; - let header_prefix_width = header_line.width(); - - let cmd_display = if call.is_unified_exec_interaction() { - format_unified_exec_interaction(&call.command, call.interaction_input.as_deref()) - } else { - strip_bash_lc_and_escape(&call.command) - }; - let highlighted_lines = highlight_bash_to_lines(&cmd_display); - - let continuation_wrap_width = layout.command_continuation.wrap_width(width); - let continuation_opts = - RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation); - - let mut continuation_lines: Vec> = Vec::new(); - - if let Some((first, rest)) = highlighted_lines.split_first() { - let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1); - let first_opts = - RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation); - let mut first_wrapped: Vec> = Vec::new(); - push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped); - let mut first_wrapped_iter = first_wrapped.into_iter(); - if let Some(first_segment) = first_wrapped_iter.next() { - header_line.extend(first_segment); - } - continuation_lines.extend(first_wrapped_iter); - - for line in rest { - push_owned_lines( - &word_wrap_line(line, continuation_opts.clone()), - &mut continuation_lines, - ); - } - } - - let mut lines: Vec> = vec![header_line]; - - let continuation_lines = Self::limit_lines_from_start( - &continuation_lines, - layout.command_continuation_max_lines, - ); - if !continuation_lines.is_empty() { - lines.extend(prefix_lines( - continuation_lines, - Span::from(layout.command_continuation.initial_prefix).dim(), - Span::from(layout.command_continuation.subsequent_prefix).dim(), - )); - } - - if let Some(output) = call.output.as_ref() { - let line_limit = if call.is_user_shell_command() { - USER_SHELL_TOOL_CALL_MAX_LINES - } else { - TOOL_CALL_MAX_LINES - }; - let raw_output = output_lines( - Some(output), - OutputLinesParams { - line_limit, - only_err: false, - include_angle_pipe: false, - include_prefix: false, - }, - ); - let display_limit = if call.is_user_shell_command() { - USER_SHELL_TOOL_CALL_MAX_LINES - } else { - layout.output_max_lines - }; - - if raw_output.lines.is_empty() { - if !call.is_unified_exec_interaction() { - lines.extend(prefix_lines( - vec![Line::from("(no output)".dim())], - Span::from(layout.output_block.initial_prefix).dim(), - Span::from(layout.output_block.subsequent_prefix), - )); - } - } else { - // Wrap first so that truncation is applied to on-screen lines - // rather than logical lines. This ensures that a small number - // of very long lines cannot flood the viewport. - let mut wrapped_output: Vec> = Vec::new(); - let output_wrap_width = layout.output_block.wrap_width(width); - let output_opts = - RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); - for line in &raw_output.lines { - push_owned_lines( - &word_wrap_line(line, output_opts.clone()), - &mut wrapped_output, - ); - } - - let trimmed_output = - Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); - - if !trimmed_output.is_empty() { - lines.extend(prefix_lines( - trimmed_output, - Span::from(layout.output_block.initial_prefix).dim(), - Span::from(layout.output_block.subsequent_prefix), - )); - } - } - } - - lines - } - - fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec> { - if lines.len() <= keep { - return lines.to_vec(); - } - if keep == 0 { - return vec![Self::ellipsis_line(lines.len())]; - } - - let mut out: Vec> = lines[..keep].to_vec(); - out.push(Self::ellipsis_line(lines.len() - keep)); - out - } - - fn truncate_lines_middle( - lines: &[Line<'static>], - max: usize, - omitted_hint: Option, - ) -> Vec> { - if max == 0 { - return Vec::new(); - } - if lines.len() <= max { - return lines.to_vec(); - } - if max == 1 { - // Carry forward any previously omitted count and add any - // additionally hidden content lines from this truncation. - let base = omitted_hint.unwrap_or(0); - // When an existing ellipsis is present, `lines` already includes - // that single representation line; exclude it from the count of - // additionally omitted content lines. - let extra = lines - .len() - .saturating_sub(usize::from(omitted_hint.is_some())); - let omitted = base + extra; - return vec![Self::ellipsis_line(omitted)]; - } - - let head = (max - 1) / 2; - let tail = max - head - 1; - let mut out: Vec> = Vec::new(); - - if head > 0 { - out.extend(lines[..head].iter().cloned()); - } - - let base = omitted_hint.unwrap_or(0); - let additional = lines - .len() - .saturating_sub(head + tail) - .saturating_sub(usize::from(omitted_hint.is_some())); - out.push(Self::ellipsis_line(base + additional)); - - if tail > 0 { - out.extend(lines[lines.len() - tail..].iter().cloned()); - } - - out - } - - fn ellipsis_line(omitted: usize) -> Line<'static> { - Line::from(vec![format!("… +{omitted} lines").dim()]) - } -} - -#[derive(Clone, Copy)] -struct PrefixedBlock { - initial_prefix: &'static str, - subsequent_prefix: &'static str, -} - -impl PrefixedBlock { - const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self { - Self { - initial_prefix, - subsequent_prefix, - } - } - - fn wrap_width(self, total_width: u16) -> usize { - let prefix_width = UnicodeWidthStr::width(self.initial_prefix) - .max(UnicodeWidthStr::width(self.subsequent_prefix)); - usize::from(total_width).saturating_sub(prefix_width).max(1) - } -} - -#[derive(Clone, Copy)] -struct ExecDisplayLayout { - command_continuation: PrefixedBlock, - command_continuation_max_lines: usize, - output_block: PrefixedBlock, - output_max_lines: usize, -} - -impl ExecDisplayLayout { - const fn new( - command_continuation: PrefixedBlock, - command_continuation_max_lines: usize, - output_block: PrefixedBlock, - output_max_lines: usize, - ) -> Self { - Self { - command_continuation, - command_continuation_max_lines, - output_block, - output_max_lines, - } - } -} - -const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( - PrefixedBlock::new(" │ ", " │ "), - 2, - PrefixedBlock::new(" └ ", " "), - 5, -); - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::protocol::ExecCommandSource; - - #[test] - fn user_shell_output_is_limited_by_screen_lines() { - // Construct a user shell exec cell whose aggregated output consists of a - // small number of very long logical lines. These will wrap into many - // on-screen lines at narrow widths. - // - // Use a short marker so it survives wrapping intact inside each - // rendered screen line; the previous test used a marker longer than - // the wrap width, so it was split across lines and the assertion - // never actually saw it. - let marker = "Z"; - let long_chunk = marker.repeat(800); - let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); - - // Baseline: how many screen lines would we get if we simply wrapped - // all logical lines without any truncation? - let output = CommandOutput { - exit_code: 0, - aggregated_output, - formatted_output: String::new(), - }; - let width = 20; - let layout = EXEC_DISPLAY_LAYOUT; - let raw_output = output_lines( - Some(&output), - OutputLinesParams { - // Large enough to include all logical lines without - // triggering the ellipsis in `output_lines`. - line_limit: 100, - only_err: false, - include_angle_pipe: false, - include_prefix: false, - }, - ); - let output_wrap_width = layout.output_block.wrap_width(width); - let output_opts = - RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); - let mut full_wrapped_output: Vec> = Vec::new(); - for line in &raw_output.lines { - push_owned_lines( - &word_wrap_line(line, output_opts.clone()), - &mut full_wrapped_output, - ); - } - let full_screen_lines = full_wrapped_output - .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); - - // Sanity check: this scenario should produce more screen lines than - // the user shell per-call limit when no truncation is applied. If - // this ever fails, the test no longer exercises the regression. - assert!( - full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES, - "expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}", - ); - - let call = ExecCall { - call_id: "call-id".to_string(), - command: vec!["bash".into(), "-lc".into(), "echo long".into()], - parsed: Vec::new(), - output: Some(output), - source: ExecCommandSource::UserShell, - start_time: None, - duration: None, - interaction_input: None, - }; - - let cell = ExecCell::new(call, false); - - // Use a narrow width so each logical line wraps into many on-screen lines. - let lines = cell.command_display_lines(width); - - // Count how many rendered lines contain our marker text. This approximates - // the number of visible output "screen lines" for this command. - let output_screen_lines = lines - .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); - - // Regression guard: previously this scenario could render hundreds of - // wrapped lines because truncation happened before wrapping. Now the - // truncation is applied after wrapping, so the number of visible - // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. - assert!( - output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, - "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", - ); - } -} diff --git a/codex-rs/tui2/src/exec_command.rs b/codex-rs/tui2/src/exec_command.rs deleted file mode 100644 index 8ce6c2632e..0000000000 --- a/codex-rs/tui2/src/exec_command.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; - -use codex_core::parse_command::extract_shell_command; -use dirs::home_dir; -use shlex::try_join; - -pub(crate) fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) -} - -pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { - if let Some((_, script)) = extract_shell_command(command) { - return script.to_string(); - } - escape_command(command) -} - -/// If `path` is absolute and inside $HOME, return the part *after* the home -/// directory; otherwise, return the path as-is. Note if `path` is the homedir, -/// this will return and empty path. -pub(crate) fn relativize_to_home

(path: P) -> Option -where - P: AsRef, -{ - let path = path.as_ref(); - if !path.is_absolute() { - // If the path is not absolute, we can’t do anything with it. - return None; - } - - let home_dir = home_dir()?; - let rel = path.strip_prefix(&home_dir).ok()?; - Some(rel.to_path_buf()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_escape_command() { - let args = vec!["foo".into(), "bar baz".into(), "weird&stuff".into()]; - let cmdline = escape_command(&args); - assert_eq!(cmdline, "foo 'bar baz' 'weird&stuff'"); - } - - #[test] - fn test_strip_bash_lc_and_escape() { - // Test bash - let args = vec!["bash".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - - // Test zsh - let args = vec!["zsh".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - - // Test absolute path to zsh - let args = vec!["/usr/bin/zsh".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - - // Test absolute path to bash - let args = vec!["/bin/bash".into(), "-lc".into(), "echo hello".into()]; - let cmdline = strip_bash_lc_and_escape(&args); - assert_eq!(cmdline, "echo hello"); - } -} diff --git a/codex-rs/tui2/src/file_search.rs b/codex-rs/tui2/src/file_search.rs deleted file mode 100644 index af46512640..0000000000 --- a/codex-rs/tui2/src/file_search.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! Helper that owns the debounce/cancellation logic for `@` file searches. -//! -//! `ChatComposer` publishes *every* change of the `@token` as -//! `AppEvent::StartFileSearch(query)`. -//! This struct receives those events and decides when to actually spawn the -//! expensive search (handled in the main `App` thread). It tries to ensure: -//! -//! - Even when the user types long text quickly, they will start seeing results -//! after a short delay using an early version of what they typed. -//! - At most one search is in-flight at any time. -//! -//! It works as follows: -//! -//! 1. First query starts a debounce timer. -//! 2. While the timer is pending, the latest query from the user is stored. -//! 3. When the timer fires, it is cleared, and a search is done for the most -//! recent query. -//! 4. If there is a in-flight search that is not a prefix of the latest thing -//! the user typed, it is cancelled. - -use codex_file_search as file_search; -use std::num::NonZeroUsize; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::thread; -use std::time::Duration; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap(); -const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); - -/// How long to wait after a keystroke before firing the first search when none -/// is currently running. Keeps early queries more meaningful. -const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); - -const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); - -/// State machine for file-search orchestration. -pub(crate) struct FileSearchManager { - /// Unified state guarded by one mutex. - state: Arc>, - - search_dir: PathBuf, - app_tx: AppEventSender, -} - -struct SearchState { - /// Latest query typed by user (updated every keystroke). - latest_query: String, - - /// true if a search is currently scheduled. - is_search_scheduled: bool, - - /// If there is an active search, this will be the query being searched. - active_search: Option, -} - -struct ActiveSearch { - query: String, - cancellation_token: Arc, -} - -impl FileSearchManager { - pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { - Self { - state: Arc::new(Mutex::new(SearchState { - latest_query: String::new(), - is_search_scheduled: false, - active_search: None, - })), - search_dir, - app_tx: tx, - } - } - - /// Call whenever the user edits the `@` token. - pub fn on_user_query(&self, query: String) { - { - #[expect(clippy::unwrap_used)] - let mut st = self.state.lock().unwrap(); - if query == st.latest_query { - // No change, nothing to do. - return; - } - - // Update latest query. - st.latest_query.clear(); - st.latest_query.push_str(&query); - - // If there is an in-flight search that is definitely obsolete, - // cancel it now. - if let Some(active_search) = &st.active_search - && !query.starts_with(&active_search.query) - { - active_search - .cancellation_token - .store(true, Ordering::Relaxed); - st.active_search = None; - } - - // Schedule a search to run after debounce. - if !st.is_search_scheduled { - st.is_search_scheduled = true; - } else { - return; - } - } - - // If we are here, we set `st.is_search_scheduled = true` before - // dropping the lock. This means we are the only thread that can spawn a - // debounce timer. - let state = self.state.clone(); - let search_dir = self.search_dir.clone(); - let tx_clone = self.app_tx.clone(); - thread::spawn(move || { - // Always do a minimum debounce, but then poll until the - // `active_search` is cleared. - thread::sleep(FILE_SEARCH_DEBOUNCE); - loop { - #[expect(clippy::unwrap_used)] - if state.lock().unwrap().active_search.is_none() { - break; - } - thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL); - } - - // The debounce timer has expired, so start a search using the - // latest query. - let cancellation_token = Arc::new(AtomicBool::new(false)); - let token = cancellation_token.clone(); - let query = { - #[expect(clippy::unwrap_used)] - let mut st = state.lock().unwrap(); - let query = st.latest_query.clone(); - st.is_search_scheduled = false; - st.active_search = Some(ActiveSearch { - query: query.clone(), - cancellation_token: token, - }); - query - }; - - FileSearchManager::spawn_file_search( - query, - search_dir, - tx_clone, - cancellation_token, - state, - ); - }); - } - - fn spawn_file_search( - query: String, - search_dir: PathBuf, - tx: AppEventSender, - cancellation_token: Arc, - search_state: Arc>, - ) { - let compute_indices = true; - std::thread::spawn(move || { - let matches = file_search::run( - &query, - MAX_FILE_SEARCH_RESULTS, - &search_dir, - Vec::new(), - NUM_FILE_SEARCH_THREADS, - cancellation_token.clone(), - compute_indices, - true, - ) - .map(|res| res.matches) - .unwrap_or_default(); - - let is_cancelled = cancellation_token.load(Ordering::Relaxed); - if !is_cancelled { - tx.send(AppEvent::FileSearchResult { query, matches }); - } - - // Reset the active search state. Do a pointer comparison to verify - // that we are clearing the ActiveSearch that corresponds to the - // cancellation token we were given. - { - #[expect(clippy::unwrap_used)] - let mut st = search_state.lock().unwrap(); - if let Some(active_search) = &st.active_search - && Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) - { - st.active_search = None; - } - } - }); - } -} diff --git a/codex-rs/tui2/src/frames.rs b/codex-rs/tui2/src/frames.rs deleted file mode 100644 index 19a70578d4..0000000000 --- a/codex-rs/tui2/src/frames.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::time::Duration; - -// Embed animation frames for each variant at compile time. -macro_rules! frames_for { - ($dir:literal) => { - [ - include_str!(concat!("../frames/", $dir, "/frame_1.txt")), - include_str!(concat!("../frames/", $dir, "/frame_2.txt")), - include_str!(concat!("../frames/", $dir, "/frame_3.txt")), - include_str!(concat!("../frames/", $dir, "/frame_4.txt")), - include_str!(concat!("../frames/", $dir, "/frame_5.txt")), - include_str!(concat!("../frames/", $dir, "/frame_6.txt")), - include_str!(concat!("../frames/", $dir, "/frame_7.txt")), - include_str!(concat!("../frames/", $dir, "/frame_8.txt")), - include_str!(concat!("../frames/", $dir, "/frame_9.txt")), - include_str!(concat!("../frames/", $dir, "/frame_10.txt")), - include_str!(concat!("../frames/", $dir, "/frame_11.txt")), - include_str!(concat!("../frames/", $dir, "/frame_12.txt")), - include_str!(concat!("../frames/", $dir, "/frame_13.txt")), - include_str!(concat!("../frames/", $dir, "/frame_14.txt")), - include_str!(concat!("../frames/", $dir, "/frame_15.txt")), - include_str!(concat!("../frames/", $dir, "/frame_16.txt")), - include_str!(concat!("../frames/", $dir, "/frame_17.txt")), - include_str!(concat!("../frames/", $dir, "/frame_18.txt")), - include_str!(concat!("../frames/", $dir, "/frame_19.txt")), - include_str!(concat!("../frames/", $dir, "/frame_20.txt")), - include_str!(concat!("../frames/", $dir, "/frame_21.txt")), - include_str!(concat!("../frames/", $dir, "/frame_22.txt")), - include_str!(concat!("../frames/", $dir, "/frame_23.txt")), - include_str!(concat!("../frames/", $dir, "/frame_24.txt")), - include_str!(concat!("../frames/", $dir, "/frame_25.txt")), - include_str!(concat!("../frames/", $dir, "/frame_26.txt")), - include_str!(concat!("../frames/", $dir, "/frame_27.txt")), - include_str!(concat!("../frames/", $dir, "/frame_28.txt")), - include_str!(concat!("../frames/", $dir, "/frame_29.txt")), - include_str!(concat!("../frames/", $dir, "/frame_30.txt")), - include_str!(concat!("../frames/", $dir, "/frame_31.txt")), - include_str!(concat!("../frames/", $dir, "/frame_32.txt")), - include_str!(concat!("../frames/", $dir, "/frame_33.txt")), - include_str!(concat!("../frames/", $dir, "/frame_34.txt")), - include_str!(concat!("../frames/", $dir, "/frame_35.txt")), - include_str!(concat!("../frames/", $dir, "/frame_36.txt")), - ] - }; -} - -pub(crate) const FRAMES_DEFAULT: [&str; 36] = frames_for!("default"); -pub(crate) const FRAMES_CODEX: [&str; 36] = frames_for!("codex"); -pub(crate) const FRAMES_OPENAI: [&str; 36] = frames_for!("openai"); -pub(crate) const FRAMES_BLOCKS: [&str; 36] = frames_for!("blocks"); -pub(crate) const FRAMES_DOTS: [&str; 36] = frames_for!("dots"); -pub(crate) const FRAMES_HASH: [&str; 36] = frames_for!("hash"); -pub(crate) const FRAMES_HBARS: [&str; 36] = frames_for!("hbars"); -pub(crate) const FRAMES_VBARS: [&str; 36] = frames_for!("vbars"); -pub(crate) const FRAMES_SHAPES: [&str; 36] = frames_for!("shapes"); -pub(crate) const FRAMES_SLUG: [&str; 36] = frames_for!("slug"); - -pub(crate) const ALL_VARIANTS: &[&[&str]] = &[ - &FRAMES_DEFAULT, - &FRAMES_CODEX, - &FRAMES_OPENAI, - &FRAMES_BLOCKS, - &FRAMES_DOTS, - &FRAMES_HASH, - &FRAMES_HBARS, - &FRAMES_VBARS, - &FRAMES_SHAPES, - &FRAMES_SLUG, -]; - -pub(crate) const FRAME_TICK_DEFAULT: Duration = Duration::from_millis(80); diff --git a/codex-rs/tui2/src/get_git_diff.rs b/codex-rs/tui2/src/get_git_diff.rs deleted file mode 100644 index 78ab53d92f..0000000000 --- a/codex-rs/tui2/src/get_git_diff.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Utility to compute the current Git diff for the working directory. -//! -//! The implementation mirrors the behaviour of the TypeScript version in -//! `codex-cli`: it returns the diff for tracked changes as well as any -//! untracked files. When the current directory is not inside a Git -//! repository, the function returns `Ok((false, String::new()))`. - -use std::io; -use std::path::Path; -use std::process::Stdio; -use tokio::process::Command; - -/// Return value of [`get_git_diff`]. -/// -/// * `bool` – Whether the current working directory is inside a Git repo. -/// * `String` – The concatenated diff (may be empty). -pub(crate) async fn get_git_diff() -> io::Result<(bool, String)> { - // First check if we are inside a Git repository. - if !inside_git_repo().await? { - return Ok((false, String::new())); - } - - // Run tracked diff and untracked file listing in parallel. - let (tracked_diff_res, untracked_output_res) = tokio::join!( - run_git_capture_diff(&["diff", "--color"]), - run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"]), - ); - let tracked_diff = tracked_diff_res?; - let untracked_output = untracked_output_res?; - - let mut untracked_diff = String::new(); - let null_device: &Path = if cfg!(windows) { - Path::new("NUL") - } else { - Path::new("/dev/null") - }; - - let null_path = null_device.to_str().unwrap_or("/dev/null").to_string(); - let mut join_set: tokio::task::JoinSet> = tokio::task::JoinSet::new(); - for file in untracked_output - .split('\n') - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let null_path = null_path.clone(); - let file = file.to_string(); - join_set.spawn(async move { - let args = ["diff", "--color", "--no-index", "--", &null_path, &file]; - run_git_capture_diff(&args).await - }); - } - while let Some(res) = join_set.join_next().await { - match res { - Ok(Ok(diff)) => untracked_diff.push_str(&diff), - Ok(Err(err)) if err.kind() == io::ErrorKind::NotFound => {} - Ok(Err(err)) => return Err(err), - Err(_) => {} - } - } - - Ok((true, format!("{tracked_diff}{untracked_diff}"))) -} - -/// Helper that executes `git` with the given `args` and returns `stdout` as a -/// UTF-8 string. Any non-zero exit status is considered an *error*. -async fn run_git_capture_stdout(args: &[&str]) -> io::Result { - let output = Command::new("git") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(io::Error::other(format!( - "git {:?} failed with status {}", - args, output.status - ))) - } -} - -/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and -/// returns stdout. Git returns 1 for diffs when differences are present. -async fn run_git_capture_diff(args: &[&str]) -> io::Result { - let output = Command::new("git") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await?; - - if output.status.success() || output.status.code() == Some(1) { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - Err(io::Error::other(format!( - "git {:?} failed with status {}", - args, output.status - ))) - } -} - -/// Determine if the current directory is inside a Git repository. -async fn inside_git_repo() -> io::Result { - let status = Command::new("git") - .args(["rev-parse", "--is-inside-work-tree"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; - - match status { - Ok(s) if s.success() => Ok(true), - Ok(_) => Ok(false), - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed - Err(e) => Err(e), - } -} diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs deleted file mode 100644 index 757f4ae2c3..0000000000 --- a/codex-rs/tui2/src/history_cell.rs +++ /dev/null @@ -1,2880 +0,0 @@ -//! Transcript/history cells for the Codex TUI. -//! -//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed -//! transcript entries and, transiently, an in-flight active cell that can mutate in place while -//! streaming. -//! -//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and -//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on -//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place -//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the -//! rendered transcript output can change. - -use crate::diff_render::create_diff_summary; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::OutputLinesParams; -use crate::exec_cell::TOOL_CALL_MAX_LINES; -use crate::exec_cell::output_lines; -use crate::exec_cell::spinner; -use crate::exec_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::markdown::append_markdown; -use crate::render::line_utils::line_to_static; -use crate::render::line_utils::prefix_lines; -use crate::render::renderable::Renderable; -use crate::style::user_message_style; -use crate::text_formatting::format_and_truncate_tool_result; -use crate::text_formatting::truncate_text; -use crate::tooltips; -use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use base64::Engine; -use codex_common::format_env_display::format_env_display; -use codex_core::config::Config; -use codex_core::config::types::McpServerTransportConfig; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpAuthStatus; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::SessionConfiguredEvent; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use image::DynamicImage; -use image::ImageReader; -use mcp_types::EmbeddedResourceResource; -use mcp_types::Resource; -use mcp_types::ResourceLink; -use mcp_types::ResourceTemplate; -use ratatui::prelude::*; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Styled; -use ratatui::style::Stylize; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use std::any::Any; -use std::collections::HashMap; -use std::io::Cursor; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; -use tracing::error; -use unicode_width::UnicodeWidthStr; - -/// Visual transcript lines plus soft-wrap joiners. -/// -/// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are -/// applied. Clipboard reconstruction needs more information than just those lines because users -/// expect soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer -/// rows should remain hard breaks. -/// -/// `joiner_before` records, for each output line, whether it is a continuation created by the -/// wrapping algorithm and what string should be inserted at the wrap boundary when joining lines. -/// This avoids heuristics like always inserting a space, and instead preserves the exact whitespace -/// that was skipped at the boundary. -/// -/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally does not -/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries. In -/// `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span -/// content that is not currently visible, so we need extra metadata to distinguish hard breaks from -/// soft wraps and to preserve the exact whitespace at wrap boundaries. -/// -/// The invariant is that `joiner_before.len() == lines.len()` and `joiner_before[0]` is always -/// `None`. A `None` entry represents a hard break (copy inserts a newline), while `Some(joiner)` -/// represents a soft wrap continuation (copy inserts `joiner` and continues on the same logical -/// line). This data is produced by transcript rendering and consumed by transcript copy to keep -/// clipboard output faithful to what the user saw. -#[derive(Debug, Clone)] -pub(crate) struct TranscriptLinesWithJoiners { - /// Visual transcript lines for a history cell, including any indent/prefix spans. - /// - /// This is the same shape used for on-screen transcript rendering: a single cell may expand - /// to multiple `Line`s after wrapping and prefixing. - pub(crate) lines: Vec>, - /// For each output line, whether and how to join it to the previous line when copying. - pub(crate) joiner_before: Vec>, -} - -/// Represents an event to display in the conversation history. Returns its -/// `Vec>` representation to make it easier to display in a -/// scrollable list. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { - fn display_lines(&self, width: u16) -> Vec>; - - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.display_lines(width))) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) - } - - /// Transcript lines plus soft-wrap joiners used for copy/paste fidelity. - /// - /// Most cells can use the default implementation (no joiners), but cells that apply wrapping - /// should override this and return joiners derived from the same wrapping operation so - /// clipboard reconstruction can distinguish hard breaks from soft wraps. - /// - /// `joiner_before[i]` describes the boundary *between* `lines[i - 1]` and `lines[i]`: - /// - /// - `None` means "hard break": copy inserts a newline between the two lines. - /// - `Some(joiner)` means "soft wrap continuation": copy inserts `joiner` and continues on the - /// same logical line. - /// - /// Example (one logical line wrapped across two visual lines): - /// - /// - `lines = ["• Hello", " world"]` - /// - `joiner_before = [None, Some(\" \")]` - /// - /// Copy should produce `"Hello world"` (no hard newline). - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - let lines = self.transcript_lines(width); - TranscriptLinesWithJoiners { - joiner_before: vec![None; lines.len()], - lines, - } - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); - // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. - if let [line] = &lines[..] - && line - .spans - .iter() - .all(|s| s.content.chars().all(char::is_whitespace)) - { - return 1; - } - - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn is_stream_continuation(&self) -> bool { - false - } - - /// Returns a coarse "animation tick" when transcript output is time-dependent. - /// - /// The transcript overlay caches the rendered output of the in-flight active cell, so cells - /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over - /// time to signal that the cached tail should be recomputed. Returning `None` means the - /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation - /// allows the overlay to keep up with the main viewport. - /// - /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on - /// the first rendered frame even though the main viewport is animating. - fn transcript_animation_tick(&self) -> Option { - None - } -} - -impl Renderable for Box { - fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); - let y = if area.height == 0 { - 0 - } else { - let overflow = lines.len().saturating_sub(usize::from(area.height)); - u16::try_from(overflow).unwrap_or(u16::MAX) - }; - Paragraph::new(Text::from(lines)) - .scroll((y, 0)) - .render(area, buf); - } - fn desired_height(&self, width: u16) -> u16 { - HistoryCell::desired_height(self.as_ref(), width) - } -} - -impl dyn HistoryCell { - pub(crate) fn as_any(&self) -> &dyn Any { - self - } - - pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[derive(Debug)] -pub(crate) struct UserHistoryCell { - pub message: String, -} - -impl HistoryCell for UserHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - self.transcript_lines_with_joiners(width).lines - } - - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - let wrap_width = width - .saturating_sub( - LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ - ) - .max(1); - - let style = user_message_style(); - - let (wrapped, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners( - self.message.lines().map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - - let mut lines: Vec> = Vec::new(); - let mut joins: Vec> = Vec::new(); - - lines.push(Line::from("").style(style)); - joins.push(None); - - let prefixed = prefix_lines(wrapped, "› ".bold().dim(), " ".into()); - for (line, joiner) in prefixed.into_iter().zip(joiner_before) { - lines.push(line); - joins.push(joiner); - } - - lines.push(Line::from("").style(style)); - joins.push(None); - - TranscriptLinesWithJoiners { - lines, - joiner_before: joins, - } - } -} - -#[derive(Debug)] -pub(crate) struct ReasoningSummaryCell { - _header: String, - content: String, - transcript_only: bool, -} - -impl ReasoningSummaryCell { - pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { - Self { - _header: header, - content, - transcript_only, - } - } - - fn lines(&self, width: u16) -> Vec> { - self.lines_with_joiners(width).lines - } - - fn lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - let mut lines: Vec> = Vec::new(); - append_markdown( - &self.content, - Some((width as usize).saturating_sub(2)), - &mut lines, - ); - let summary_style = Style::default().dim().italic(); - let summary_lines = lines - .into_iter() - .map(|mut line| { - line.spans = line - .spans - .into_iter() - .map(|span| span.patch_style(summary_style)) - .collect(); - line - }) - .collect::>(); - - let (lines, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners( - &summary_lines, - RtOptions::new(width as usize) - .initial_indent("• ".dim().into()) - .subsequent_indent(" ".into()), - ); - - TranscriptLinesWithJoiners { - lines, - joiner_before, - } - } -} - -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - self.lines(width) - } - } - - fn desired_height(&self, width: u16) -> u16 { - if self.transcript_only { - 0 - } else { - self.lines(width).len() as u16 - } - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.lines(width) - } - - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - self.lines_with_joiners(width) - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 - } -} - -#[derive(Debug)] -pub(crate) struct AgentMessageCell { - /// Width-agnostic logical markdown lines for this chunk. - /// - /// These are produced either: - /// - by streaming (`markdown_stream` → `markdown_render::render_markdown_logical_lines`), or - /// - by legacy/non-streaming callers that pass pre-rendered `Vec` via [`Self::new`]. - /// - /// Importantly, this stores *logical* lines, not already-wrapped visual lines, so the transcript - /// can reflow on resize. - logical_lines: Vec, - /// Whether this cell should render the leading transcript bullet (`• `). - /// - /// Streaming emits multiple immutable `AgentMessageCell`s per assistant message; only the first - /// chunk shows the bullet. Continuations use a two-space gutter. - is_first_line: bool, -} - -impl AgentMessageCell { - /// Construct an agent message cell from already-rendered `Line`s. - /// - /// This is primarily used by non-streaming paths. The lines are treated as already "logical" - /// lines (no additional markdown indentation metadata is available), and wrapping is still - /// performed at render time so the transcript can reflow on resize. - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - logical_lines: lines - .into_iter() - .map(|line| { - let is_preformatted = line.style.fg == Some(ratatui::style::Color::Cyan); - let line_style = line.style; - let content = Line { - style: Style::default(), - alignment: line.alignment, - spans: line.spans, - }; - crate::markdown_render::MarkdownLogicalLine { - content, - initial_indent: Line::default(), - subsequent_indent: Line::default(), - line_style, - is_preformatted, - } - }) - .collect(), - is_first_line, - } - } - - /// Construct an agent message cell from markdown logical lines. - /// - /// This is the preferred streaming constructor: it preserves markdown indentation rules (list - /// markers, nested list continuation indent, blockquote prefix, etc.) so wrapping can be - /// performed correctly at render time for the current viewport width. - pub(crate) fn new_logical( - logical_lines: Vec, - is_first_line: bool, - ) -> Self { - Self { - logical_lines, - is_first_line, - } - } -} - -impl HistoryCell for AgentMessageCell { - fn display_lines(&self, width: u16) -> Vec> { - self.transcript_lines_with_joiners(width).lines - } - - /// Render wrapped transcript lines plus soft-wrap joiners. - /// - /// This is where width-dependent wrapping happens for streaming agent output. The cell composes - /// indentation as: - /// - /// - transcript gutter (`• ` or ` `), plus - /// - markdown-provided indent/prefix spans (`initial_indent` / `subsequent_indent`) - /// - /// The wrapping algorithm returns a `joiner_before` vector so copy/paste can treat soft wraps - /// as joinable (no hard newline) while preserving exact whitespace at wrap boundaries. - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - if width == 0 { - return TranscriptLinesWithJoiners { - lines: Vec::new(), - joiner_before: Vec::new(), - }; - } - - let mut out_lines: Vec> = Vec::new(); - let mut joiner_before: Vec> = Vec::new(); - - // `at_cell_start` tracks whether we're about to emit the first *visual* line of this cell. - // Only the first chunk of a streamed message gets the `• ` gutter; continuations use ` `. - let mut at_cell_start = true; - for logical in &self.logical_lines { - let gutter_first_visual_line: Line<'static> = if at_cell_start && self.is_first_line { - "• ".dim().into() - } else { - " ".into() - }; - let gutter_continuation: Line<'static> = " ".into(); - - // Compose the transcript gutter with markdown-provided indentation: - // - // - `gutter_*` is the transcript-level prefix (`• ` / ` `). - // - `initial_indent` / `subsequent_indent` come from markdown structure (blockquote - // prefix, list marker indentation, nested list continuation indentation, etc.). - // - // We apply these indents during wrapping so: - // - the UI renders with correct continuation indentation, and - // - soft-wrap joiners stay aligned with the exact whitespace the wrapper skipped. - let compose_indent = - |gutter: &Line<'static>, md_indent: &Line<'static>| -> Line<'static> { - let mut spans = gutter.spans.clone(); - spans.extend(md_indent.spans.iter().cloned()); - Line::from(spans) - }; - - // Preformatted lines are rendered as a single visual line (no wrapping). - // This preserves code-block whitespace and keeps code copy behavior stable. - if logical.is_preformatted { - let mut spans = gutter_first_visual_line.spans.clone(); - spans.extend(logical.initial_indent.spans.iter().cloned()); - spans.extend(logical.content.spans.iter().cloned()); - out_lines.push(Line::from(spans).style(logical.line_style)); - joiner_before.push(None); - at_cell_start = false; - continue; - } - - // Prose path: wrap to current width and capture joiners. - // - // `word_wrap_line_with_joiners` guarantees: - // - `wrapped.len() == wrapped_joiners.len()` - // - `wrapped_joiners[0] == None` (first visual segment of a logical line is a hard break) - // - subsequent entries are `Some(joiner)` (soft-wrap continuations). - let opts = RtOptions::new(width as usize) - .initial_indent(compose_indent( - &gutter_first_visual_line, - &logical.initial_indent, - )) - .subsequent_indent(compose_indent( - &gutter_continuation, - &logical.subsequent_indent, - )); - - let (wrapped, wrapped_joiners) = - crate::wrapping::word_wrap_line_with_joiners(&logical.content, opts); - for (visual, joiner) in wrapped.into_iter().zip(wrapped_joiners) { - out_lines.push(line_to_static(&visual).style(logical.line_style)); - joiner_before.push(joiner); - at_cell_start = false; - } - } - - debug_assert_eq!(out_lines.len(), joiner_before.len()); - debug_assert!( - joiner_before - .first() - .is_none_or(std::option::Option::is_none) - ); - - TranscriptLinesWithJoiners { - lines: out_lines, - joiner_before, - } - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -#[derive(Debug)] -pub(crate) struct PlainHistoryCell { - lines: Vec>, -} - -impl PlainHistoryCell { - pub(crate) fn new(lines: Vec>) -> Self { - Self { lines } - } -} - -impl HistoryCell for PlainHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -#[derive(Debug)] -pub(crate) struct UpdateAvailableHistoryCell { - latest_version: String, - update_action: Option, -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { - Self { - latest_version, - update_action, - } - } -} - -impl HistoryCell for UpdateAvailableHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - use ratatui_macros::line; - use ratatui_macros::text; - let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", update_action.command_str().cyan(), " to update."] - } else { - line![ - "See ", - "https://github.com/openai/codex".cyan().underlined(), - " for installation options." - ] - }; - - let content = text![ - line![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), - ], - update_instruction, - "", - "See full release notes:", - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ]; - - let inner_width = content - .width() - .min(usize::from(width.saturating_sub(4))) - .max(1); - with_border_with_inner_width(content.lines, inner_width) - } -} - -#[derive(Debug)] -pub(crate) struct PrefixedWrappedHistoryCell { - text: Text<'static>, - initial_prefix: Line<'static>, - subsequent_prefix: Line<'static>, -} - -impl PrefixedWrappedHistoryCell { - pub(crate) fn new( - text: impl Into>, - initial_prefix: impl Into>, - subsequent_prefix: impl Into>, - ) -> Self { - Self { - text: text.into(), - initial_prefix: initial_prefix.into(), - subsequent_prefix: subsequent_prefix.into(), - } - } -} - -impl HistoryCell for PrefixedWrappedHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - self.transcript_lines_with_joiners(width).lines - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } - - fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { - if width == 0 { - return TranscriptLinesWithJoiners { - lines: Vec::new(), - joiner_before: Vec::new(), - }; - } - let opts = RtOptions::new(width.max(1) as usize) - .initial_indent(self.initial_prefix.clone()) - .subsequent_indent(self.subsequent_prefix.clone()); - let (lines, joiner_before) = - crate::wrapping::word_wrap_lines_with_joiners(&self.text, opts); - TranscriptLinesWithJoiners { - lines, - joiner_before, - } - } -} - -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -pub fn new_approval_decision_cell( - command: Vec, - decision: codex_core::protocol::ReviewDecision, -) -> Box { - use codex_core::protocol::ReviewDecision::*; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " this time".bold(), - ], - ) - } - ApprovedExecpolicyAmendment { .. } => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " and applied the execpolicy amendment".bold(), - ], - ) - } - ApprovedForSession => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " every time this session".bold(), - ], - ) - } - Denied => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ) - } - Abort => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "canceled".bold(), - " the request to run ".into(), - snippet, - ], - ) - } - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - -/// Cyan history cell line showing the current review status. -pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { - PlainHistoryCell { - lines: vec![Line::from(message.cyan())], - } -} - -#[derive(Debug)] -pub(crate) struct PatchHistoryCell { - changes: HashMap, - cwd: PathBuf, -} - -impl HistoryCell for PatchHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - create_diff_summary(&self.changes, &self.cwd, width as usize) - } -} - -#[derive(Debug)] -struct CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, -} -impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self, _width: u16) -> Vec> { - vec!["tool result (image output)".into()] - } -} - -pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value - -pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { - if width < 4 { - return None; - } - let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); - Some(inner_width) -} - -/// Render `lines` inside a border sized to the widest span in the content. -pub(crate) fn with_border(lines: Vec>) -> Vec> { - with_border_internal(lines, None) -} - -/// Render `lines` inside a border whose inner width is at least `inner_width`. -/// -/// This is useful when callers have already clamped their content to a -/// specific width and want the border math centralized here instead of -/// duplicating padding logic in the TUI widgets themselves. -pub(crate) fn with_border_with_inner_width( - lines: Vec>, - inner_width: usize, -) -> Vec> { - with_border_internal(lines, Some(inner_width)) -} - -fn with_border_internal( - lines: Vec>, - forced_inner_width: Option, -) -> Vec> { - let max_line_width = lines - .iter() - .map(|line| { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum::() - }) - .max() - .unwrap_or(0); - let content_width = forced_inner_width - .unwrap_or(max_line_width) - .max(max_line_width); - - let mut out = Vec::with_capacity(lines.len() + 2); - let border_inner_width = content_width + 2; - out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); - - for line in lines.into_iter() { - let used_width: usize = line - .iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum(); - let span_count = line.spans.len(); - let mut spans: Vec> = Vec::with_capacity(span_count + 4); - spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); - if used_width < content_width { - spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); - } - spans.push(Span::from(" │").dim()); - out.push(Line::from(spans)); - } - - out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); - - out -} - -/// Return the emoji followed by a hair space (U+200A). -/// Using only the hair space avoids excessive padding after the emoji while -/// still providing a small visual gap across terminals. -pub(crate) fn padded_emoji(emoji: &str) -> String { - format!("{emoji}\u{200A}") -} - -#[derive(Debug)] -struct TooltipHistoryCell { - tip: String, -} - -impl TooltipHistoryCell { - fn new(tip: String) -> Self { - Self { tip } - } -} - -impl HistoryCell for TooltipHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let indent = " "; - let indent_width = UnicodeWidthStr::width(indent); - let wrap_width = usize::from(width.max(1)) - .saturating_sub(indent_width) - .max(1); - let mut lines: Vec> = Vec::new(); - append_markdown( - &format!("**Tip:** {}", self.tip), - Some(wrap_width), - &mut lines, - ); - - prefix_lines(lines, indent.into(), indent.into()) - } -} - -#[derive(Debug)] -pub struct SessionInfoCell(CompositeHistoryCell); - -impl HistoryCell for SessionInfoCell { - fn display_lines(&self, width: u16) -> Vec> { - self.0.display_lines(width) - } - - fn desired_height(&self, width: u16) -> u16 { - self.0.desired_height(width) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.0.transcript_lines(width) - } -} - -pub(crate) fn new_session_info( - config: &Config, - requested_model: &str, - event: SessionConfiguredEvent, - is_first_event: bool, -) -> SessionInfoCell { - let SessionConfiguredEvent { - model, - reasoning_effort, - .. - } = event; - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - model.clone(), - Style::default(), - reasoning_effort, - config.cwd.clone(), - CODEX_CLI_VERSION, - ); - let mut parts: Vec> = vec![Box::new(header)]; - - if is_first_event { - // Help lines below the header (new copy and list) - let help_lines: Vec> = vec![ - " To get started, describe a task or try one of these commands:" - .dim() - .into(), - Line::from(""), - Line::from(vec![ - " ".into(), - "/init".into(), - " - create an AGENTS.md file with instructions for Codex".dim(), - ]), - Line::from(vec![ - " ".into(), - "/status".into(), - " - show current session configuration".dim(), - ]), - Line::from(vec![ - " ".into(), - "/approvals".into(), - " - choose what Codex can do without approval".dim(), - ]), - Line::from(vec![ - " ".into(), - "/model".into(), - " - choose what model and reasoning effort to use".dim(), - ]), - Line::from(vec![ - " ".into(), - "/review".into(), - " - review any changes and find issues".dim(), - ]), - ]; - - parts.push(Box::new(PlainHistoryCell { lines: help_lines })); - } else { - if config.show_tooltips - && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) - { - parts.push(Box::new(tooltips)); - } - if requested_model != model { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {requested_model}").into(), - format!("used: {model}").into(), - ]; - parts.push(Box::new(PlainHistoryCell { lines })); - } - } - - SessionInfoCell(CompositeHistoryCell { parts }) -} - -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } -} - -#[derive(Debug)] -pub(crate) struct SessionHeaderHistoryCell { - version: &'static str, - model: String, - model_style: Style, - reasoning_effort: Option, - directory: PathBuf, -} - -impl SessionHeaderHistoryCell { - pub(crate) fn new( - model: String, - model_style: Style, - reasoning_effort: Option, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self::new_with_style(model, model_style, reasoning_effort, directory, version) - } - - pub(crate) fn new_with_style( - model: String, - model_style: Style, - reasoning_effort: Option, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self { - version, - model, - model_style, - reasoning_effort, - directory, - } - } - - fn format_directory(&self, max_width: Option) -> String { - Self::format_directory_inner(&self.directory, max_width) - } - - fn format_directory_inner(directory: &Path, max_width: Option) -> String { - let formatted = if let Some(rel) = relativize_to_home(directory) { - if rel.as_os_str().is_empty() { - "~".to_string() - } else { - format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) - } - } else { - directory.display().to_string() - }; - - if let Some(max_width) = max_width { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(formatted.as_str()) > max_width { - return crate::text_formatting::center_truncate_path(&formatted, max_width); - } - } - - formatted - } - - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) - } -} - -impl HistoryCell for SessionHeaderHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { - return Vec::new(); - }; - - let make_row = |spans: Vec>| Line::from(spans); - - // Title line rendered inside the box: ">_ OpenAI Codex (vX)" - let title_spans: Vec> = vec![ - Span::from(">_ ").dim(), - Span::from("OpenAI Codex").bold(), - Span::from(" ").dim(), - Span::from(format!("(v{})", self.version)).dim(), - ]; - - const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; - const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; - const DIR_LABEL: &str = "directory:"; - let label_width = DIR_LABEL.len(); - let model_label = format!( - "{model_label:> = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - model_spans.push(Span::from(" ")); - model_spans.push(Span::from(reasoning)); - } - model_spans.push(" ".dim()); - model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); - - let dir_label = format!("{DIR_LABEL:>, -} - -impl CompositeHistoryCell { - pub(crate) fn new(parts: Vec>) -> Self { - Self { parts } - } -} - -impl HistoryCell for CompositeHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.display_lines(width); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } -} - -#[derive(Debug)] -pub(crate) struct McpToolCallCell { - call_id: String, - invocation: McpInvocation, - start_time: Instant, - duration: Option, - result: Option>, - animations_enabled: bool, -} - -impl McpToolCallCell { - pub(crate) fn new( - call_id: String, - invocation: McpInvocation, - animations_enabled: bool, - ) -> Self { - Self { - call_id, - invocation, - start_time: Instant::now(), - duration: None, - result: None, - animations_enabled, - } - } - - pub(crate) fn call_id(&self) -> &str { - &self.call_id - } - - pub(crate) fn complete( - &mut self, - duration: Duration, - result: Result, - ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); - self.duration = Some(duration); - self.result = Some(result); - image_cell - } - - fn success(&self) -> Option { - match self.result.as_ref() { - Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), - Some(Err(_)) => Some(false), - None => None, - } - } - - pub(crate) fn mark_failed(&mut self) { - let elapsed = self.start_time.elapsed(); - self.duration = Some(elapsed); - self.result = Some(Err("interrupted".to_string())); - } - - fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String { - match block { - mcp_types::ContentBlock::TextContent(text) => { - format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) - } - mcp_types::ContentBlock::ImageContent(_) => "".to_string(), - mcp_types::ContentBlock::AudioContent(_) => "