diff --git a/.codespellignore b/.codespellignore index 546a192701b..d74f5ed86c9 100644 --- a/.codespellignore +++ b/.codespellignore @@ -1 +1,2 @@ iTerm +psuedo \ No newline at end of file diff --git a/.github/actions/linux-code-sign/action.yml b/.github/actions/linux-code-sign/action.yml new file mode 100644 index 00000000000..5a117b0805f --- /dev/null +++ b/.github/actions/linux-code-sign/action.yml @@ -0,0 +1,44 @@ +name: linux-code-sign +description: Sign Linux artifacts with cosign. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + artifacts-dir: + description: Absolute path to the directory containing built binaries to sign. + required: true + +runs: + using: composite + steps: + - name: Install cosign + uses: sigstore/cosign-installer@v3.7.0 + + - name: Cosign Linux artifacts + shell: bash + env: + COSIGN_EXPERIMENTAL: "1" + COSIGN_YES: "true" + COSIGN_OIDC_CLIENT_ID: "sigstore" + COSIGN_OIDC_ISSUER: "https://oauth2.sigstore.dev/auth" + run: | + set -euo pipefail + + dest="${{ inputs.artifacts-dir }}" + if [[ ! -d "$dest" ]]; then + echo "Destination $dest does not exist" + exit 1 + fi + + for binary in codex codex-responses-api-proxy; do + artifact="${dest}/${binary}" + if [[ ! -f "$artifact" ]]; then + echo "Binary $artifact not found" + exit 1 + fi + + cosign sign-blob \ + --yes \ + --bundle "${artifact}.sigstore" \ + "$artifact" + done diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 00000000000..2be64efc98b --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,55 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + cache-dependencies: false + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 08c39db69da..13fcaacbbdb 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -369,6 +369,49 @@ jobs: steps: - uses: actions/checkout@v6 + + # We have been running out of space when running this job on Linux for + # x86_64-unknown-linux-gnu, so remove some unnecessary dependencies. + - name: Remove unnecessary dependencies to save space + if: ${{ startsWith(matrix.runner, 'ubuntu') }} + shell: bash + run: | + set -euo pipefail + sudo rm -rf \ + /usr/local/lib/android \ + /usr/share/dotnet \ + /usr/local/share/boost \ + /usr/local/lib/node_modules \ + /opt/ghc + sudo apt-get remove -y docker.io docker-compose podman buildah + + # Ensure brew includes this fix so that brew's shellenv.sh loads + # cleanly in the Codex sandbox (it is frequently eval'd via .zprofile + # for Brew users, including the macOS runners on GitHub): + # + # https://github.com/Homebrew/brew/pull/21157 + # + # Once brew 5.0.5 is released and is the default on macOS runners, this + # step can be removed. + - name: Upgrade brew + if: ${{ startsWith(matrix.runner, 'macos') }} + shell: bash + run: | + set -euo pipefail + brew --version + git -C "$(brew --repo)" fetch origin + git -C "$(brew --repo)" checkout main + git -C "$(brew --repo)" reset --hard origin/main + export HOMEBREW_UPDATE_TO_TAG=0 + brew update + brew upgrade + brew --version + + # Some integration tests rely on DotSlash being installed. + # See https://github.com/openai/codex/pull/7617. + - name: Install DotSlash + uses: facebook/install-dotslash@v2 + - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 14f8aa03279..b90f0027fa3 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -50,6 +50,9 @@ jobs: name: Build - ${{ matrix.runner }} - ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 + permissions: + contents: read + id-token: write defaults: run: working-directory: codex-rs @@ -100,6 +103,25 @@ jobs: - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy + - if: ${{ contains(matrix.target, 'linux') }} + name: Cosign Linux artifacts + uses: ./.github/actions/linux-code-sign + with: + target: ${{ matrix.target }} + artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash @@ -283,6 +305,11 @@ jobs: cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" fi + if [[ "${{ matrix.target }}" == *linux* ]]; then + cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" + cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" + fi + - if: ${{ matrix.runner == 'windows-11-arm' }} name: Install zstd shell: powershell @@ -321,6 +348,11 @@ jobs: continue fi + # Don't try to compress signature bundles. + if [[ "$base" == *.sigstore ]]; then + continue + fi + # Create per-binary tar.gz tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" diff --git a/AGENTS.md b/AGENTS.md index aaebd0dfd31..f9f04c5b152 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ If you don’t have the tool: ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. +- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields. ### Integration tests (core) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 48f87efc24c..95f4fecc48e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -238,48 +238,6 @@ dependencies = [ "term", ] -[[package]] -name = "askama" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" -dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.104", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow", -] - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -557,15 +515,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "beef" version = "0.5.2" @@ -887,6 +836,7 @@ dependencies = [ "codex-file-search", "codex-login", "codex-protocol", + "codex-rmcp-client", "codex-utils-json-to-toml", "core_test_support", "mcp-types", @@ -901,6 +851,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "tracing", "tracing-subscriber", "uuid", @@ -1040,6 +991,7 @@ dependencies = [ "codex-rmcp-client", "codex-stdio-to-uds", "codex-tui", + "codex-tui2", "codex-windows-sandbox", "ctor 0.5.0", "libc", @@ -1048,7 +1000,7 @@ dependencies = [ "pretty_assertions", "regex-lite", "serde_json", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "toml", @@ -1088,10 +1040,13 @@ dependencies = [ "codex-login", "codex-tui", "crossterm", + "owo-colors", + "pretty_assertions", "ratatui", "reqwest", "serde", "serde_json", + "supports-color 3.0.2", "tokio", "tokio-stream", "tracing", @@ -1132,7 +1087,6 @@ name = "codex-core" version = "0.0.0" dependencies = [ "anyhow", - "askama", "assert_cmd", "assert_matches", "async-channel", @@ -1145,6 +1099,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-async-utils", + "codex-client", "codex-core", "codex-execpolicy", "codex-file-search", @@ -1153,6 +1108,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-utils-absolute-path", "codex-utils-pty", "codex-utils-readiness", "codex-utils-string", @@ -1237,7 +1193,7 @@ dependencies = [ "serde", "serde_json", "shlex", - "supports-color", + "supports-color 3.0.2", "tempfile", "tokio", "tracing", @@ -1253,11 +1209,14 @@ name = "codex-exec-server" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "async-trait", "clap", "codex-core", "codex-execpolicy", + "exec_server_test_support", "libc", + "maplit", "path-absolutize", "pretty_assertions", "rmcp", @@ -1270,6 +1229,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "which", ] [[package]] @@ -1295,7 +1255,7 @@ dependencies = [ "allocative", "anyhow", "clap", - "derive_more 2.0.1", + "derive_more 2.1.0", "env_logger", "log", "multimap", @@ -1456,6 +1416,7 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-protocol", + "codex-utils-absolute-path", "eventsource-stream", "http", "opentelemetry", @@ -1476,6 +1437,7 @@ name = "codex-process-hardening" version = "0.0.0" dependencies = [ "libc", + "pretty_assertions", ] [[package]] @@ -1483,7 +1445,6 @@ name = "codex-protocol" version = "0.0.0" dependencies = [ "anyhow", - "base64", "codex-git", "codex-utils-image", "icu_decimal", @@ -1586,7 +1547,76 @@ dependencies = [ "codex-windows-sandbox", "color-eyre", "crossterm", - "derive_more 2.0.1", + "derive_more 2.1.0", + "diffy", + "dirs", + "dunce", + "image", + "insta", + "itertools 0.14.0", + "lazy_static", + "libc", + "mcp-types", + "opentelemetry-appender-tracing", + "pathdiff", + "pretty_assertions", + "pulldown-cmark", + "rand 0.9.2", + "ratatui", + "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", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tree-sitter-bash", + "tree-sitter-highlight", + "unicode-segmentation", + "unicode-width 0.2.1", + "url", + "uuid", + "vt100", +] + +[[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-common", + "codex-core", + "codex-feedback", + "codex-file-search", + "codex-login", + "codex-protocol", + "codex-tui", + "codex-windows-sandbox", + "color-eyre", + "crossterm", + "derive_more 2.1.0", "diffy", "dirs", "dunce", @@ -1611,7 +1641,7 @@ dependencies = [ "shlex", "strum 0.27.2", "strum_macros 0.27.2", - "supports-color", + "supports-color 3.0.2", "tempfile", "textwrap 0.16.2", "tokio", @@ -1630,6 +1660,16 @@ dependencies = [ "vt100", ] +[[package]] +name = "codex-utils-absolute-path" +version = "0.0.0" +dependencies = [ + "path-absolutize", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-utils-cache" version = "0.0.0" @@ -1665,8 +1705,13 @@ name = "codex-utils-pty" version = "0.0.0" dependencies = [ "anyhow", + "filedescriptor", + "lazy_static", + "log", "portable-pty", + "shared_library", "tokio", + "winapi", ] [[package]] @@ -1689,6 +1734,7 @@ name = "codex-windows-sandbox" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "codex-protocol", "dirs-next", "dunce", @@ -1788,9 +1834,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -2129,11 +2175,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ - "derive_more-impl 2.0.1", + "derive_more-impl 2.1.0", ] [[package]] @@ -2151,13 +2197,14 @@ dependencies = [ [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", + "rustc_version", "syn 2.0.104", "unicode-xid", ] @@ -2498,6 +2545,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exec_server_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "codex-core", + "rmcp", + "serde_json", + "tokio", +] + [[package]] name = "eyre" version = "0.6.12" @@ -2542,7 +2601,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.0.8", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3380,9 +3439,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.2" +version = "1.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" dependencies = [ "console", "once_cell", @@ -3446,7 +3505,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4434,6 +4493,10 @@ name = "owo-colors" version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] [[package]] name = "parking" @@ -5231,7 +5294,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6170,6 +6233,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + [[package]] name = "supports-color" version = "3.0.2" @@ -6906,9 +6979,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ts-rs" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "serde_json", "thiserror 2.0.17", @@ -6918,9 +6991,9 @@ dependencies = [ [[package]] name = "ts-rs-macros" -version = "11.0.1" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ed7b4c18cc150a6a0a1e9ea1ecfa688791220781af6e119f9599a8502a0a" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", @@ -7376,9 +7449,9 @@ dependencies = [ [[package]] name = "wildmatch" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" +checksum = "29333c3ea1ba8b17211763463ff24ee84e41c78224c16b001cd907e663a38c68" [[package]] name = "winapi" @@ -7402,7 +7475,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2339cd4e677..cdf55434fe3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -34,6 +34,8 @@ members = [ "stdio-to-uds", "otel", "tui", + "tui2", + "utils/absolute-path", "utils/git", "utils/cache", "utils/image", @@ -88,6 +90,8 @@ 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-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } @@ -96,6 +100,7 @@ codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } +exec_server_test_support = { path = "exec-server/tests/common" } mcp-types = { path = "mcp-types" } mcp_test_support = { path = "mcp-server/tests/common" } @@ -104,7 +109,6 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.14" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" @@ -138,7 +142,7 @@ icu_provider = { version = "2.1", features = ["sync"] } ignore = "0.4.23" image = { version = "^0.25.9", default-features = false } indexmap = "2.12.0" -insta = "1.43.2" +insta = "1.44.3" itertools = "0.14.0" keyring = { version = "3.6", default-features = false } landlock = "0.4.1" @@ -178,8 +182,8 @@ seccompiler = "0.5.0" sentry = "0.34.0" serde = "1" serde_json = "1" -serde_yaml = "0.9" serde_with = "3.16" +serde_yaml = "0.9" serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" @@ -222,7 +226,7 @@ vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" which = "6" -wildmatch = "2.5.0" +wildmatch = "2.6.1" wiremock = "0.6" zeroize = "1.8.2" diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 28583667393..116a3c62dd4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -139,6 +139,11 @@ client_request_definitions! { response: v2::ModelListResponse, }, + McpServerOauthLogin => "mcpServer/oauth/login" { + params: v2::McpServerOauthLoginParams, + response: v2::McpServerOauthLoginResponse, + }, + McpServersList => "mcpServers/list" { params: v2::ListMcpServersParams, response: v2::ListMcpServersResponse, @@ -522,8 +527,10 @@ server_notification_definitions! { ItemCompleted => "item/completed" (v2::ItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), + McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), @@ -647,7 +654,6 @@ mod tests { command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], @@ -667,7 +673,6 @@ mod tests { "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", - "risk": null, "parsedCmd": [ { "type": "unknown", diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 1576eb0d931..853cb03b405 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -13,7 +13,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; @@ -226,7 +225,6 @@ pub struct ExecCommandApprovalParams { pub command: Vec, pub cwd: PathBuf, pub reason: Option, - pub risk: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 35d70476619..3429a4fc154 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3,8 +3,11 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; -use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::config_types::Verbosity; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; @@ -12,6 +15,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; @@ -122,17 +126,68 @@ impl From for CodexErrorInfo { } } -v2_enum_from_core!( - pub enum AskForApproval from codex_protocol::protocol::AskForApproval { - UnlessTrusted, OnFailure, OnRequest, Never +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Never => CoreAskForApproval::Never, + } } -); +} -v2_enum_from_core!( - pub enum SandboxMode from codex_protocol::config_types::SandboxMode { - ReadOnly, WorkspaceWrite, DangerFullAccess +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Never => AskForApproval::Never, + } } -); +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} v2_enum_from_core!( pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { @@ -159,6 +214,72 @@ pub enum ConfigLayerName { User, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + #[serde(alias = "web_search_request")] + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub tools: Option, + pub profile: Option, + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -237,7 +358,7 @@ pub struct ConfigReadParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadResponse { - pub config: JsonValue, + pub config: Config, pub origins: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub layers: Option>, @@ -274,19 +395,16 @@ pub struct ConfigEdit { pub merge_strategy: MergeStrategy, } -v2_enum_from_core!( - pub enum CommandRiskLevel from codex_protocol::approvals::SandboxRiskLevel { - Low, - Medium, - High - } -); - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub enum ApprovalDecision { Accept, + /// Approve and remember the approval for the session. + AcceptForSession, + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, Decline, Cancel, } @@ -356,28 +474,23 @@ impl From for SandboxPolicy { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: CommandRiskLevel, +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, } -impl SandboxCommandAssessment { - pub fn into_core(self) -> CoreSandboxCommandAssessment { - CoreSandboxCommandAssessment { - description: self.description, - risk_level: self.risk_level.to_core(), - } +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) } } -impl From for SandboxCommandAssessment { - fn from(value: CoreSandboxCommandAssessment) -> Self { +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { Self { - description: value.description, - risk_level: CommandRiskLevel::from(value.risk_level), + command: value.command().to_vec(), } } } @@ -555,10 +668,21 @@ pub struct CancelLoginAccountParams { pub login_id: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct CancelLoginAccountResponse {} +pub enum CancelLoginAccountStatus { + Canceled, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse { + pub status: CancelLoginAccountStatus, +} #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -661,6 +785,26 @@ pub struct ListMcpServersResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1410,6 +1554,17 @@ pub struct ReasoningTextDeltaNotification { pub content_index: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1440,6 +1595,17 @@ pub struct McpToolCallProgressNotification { pub message: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1466,17 +1632,8 @@ pub struct CommandExecutionRequestApprovalParams { pub item_id: String, /// Optional explanatory reason (e.g. request for network access). pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - pub risk: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestAcceptSettings { - /// If true, automatically approve this command for the duration of the session. - #[serde(default)] - pub for_session: bool, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + pub proposed_execpolicy_amendment: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1484,10 +1641,6 @@ pub struct CommandExecutionRequestAcceptSettings { #[ts(export_to = "v2/")] pub struct CommandExecutionRequestApprovalResponse { pub decision: ApprovalDecision, - /// Optional approval settings for when the decision is `accept`. - /// Ignored if the decision is `decline` or `cancel`. - #[serde(default)] - pub accept_settings: Option, } #[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 8c2a38e46c9..b66c59d55a7 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -21,7 +21,6 @@ use codex_app_server_protocol::ApprovalDecision; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::CommandExecutionRequestAcceptSettings; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::FileChangeRequestApprovalParams; @@ -554,6 +553,10 @@ impl CodexClient { print!("{}", delta.delta); std::io::stdout().flush().ok(); } + ServerNotification::TerminalInteraction(delta) => { + println!("[stdin sent: {}]", delta.stdin); + std::io::stdout().flush().ok(); + } ServerNotification::ItemStarted(payload) => { println!("\n< item started: {:?}", payload.item); } @@ -753,7 +756,7 @@ impl CodexClient { turn_id, item_id, reason, - risk, + proposed_execpolicy_amendment, } = params; println!( @@ -762,13 +765,12 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } - if let Some(risk) = risk.as_ref() { - println!("< risk assessment: {risk:?}"); + if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { + println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); } let response = CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Accept, - accept_settings: Some(CommandExecutionRequestAcceptSettings { for_session: false }), }; self.send_server_request_response(request_id, &response)?; println!("< approved commandExecution request for item {item_id}"); diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 99d5a7a1410..948facdea6f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -26,6 +26,7 @@ codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } +codex-rmcp-client = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -34,6 +35,7 @@ sha2 = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ee28637b2a0..e9dee71271f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -65,6 +65,8 @@ Example (from OpenAI's official VSCode extension): - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options). +- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `config/read` — fetch the effective config on disk after resolving config layering. @@ -366,6 +368,8 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`). - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). +- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. ### 1) Check auth state diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 94676999b53..b0161cd9fd1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; +use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileChangeRequestApprovalResponse; @@ -33,9 +34,9 @@ use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ReasoningTextDeltaNotification; -use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -178,8 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, - proposed_execpolicy_amendment: _, + proposed_execpolicy_amendment, parsed_cmd, }) => match api_version { ApiVersion::V1 => { @@ -189,7 +189,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, parsed_cmd, }; let rx = outgoing @@ -207,6 +206,8 @@ pub(crate) async fn apply_bespoke_event_handling( .map(V2ParsedCommand::from) .collect::>(); let command_string = shlex_join(&command); + let proposed_execpolicy_amendment_v2 = + proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), @@ -215,7 +216,7 @@ pub(crate) async fn apply_bespoke_event_handling( // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. item_id: item_id.clone(), reason, - risk: risk.map(V2SandboxCommandAssessment::from), + proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing .send_request(ServerRequestPayload::CommandExecutionRequestApproval( @@ -569,6 +570,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::TerminalInteraction(terminal_event) => { + let item_id = terminal_event.call_id.clone(); + + let notification = TerminalInteractionNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }; + outgoing + .send_server_notification(ServerNotification::TerminalInteraction(notification)) + .await; + } EventMsg::ExecCommandEnd(exec_command_end_event) => { let ExecCommandEndEvent { call_id, @@ -1047,7 +1062,11 @@ async fn on_file_change_request_approval_response( }); let (decision, completion_status) = match response.decision { - ApprovalDecision::Accept => (ReviewDecision::Approved, None), + ApprovalDecision::Accept + | ApprovalDecision::AcceptForSession + | ApprovalDecision::AcceptWithExecpolicyAmendment { .. } => { + (ReviewDecision::Approved, None) + } ApprovalDecision::Decline => { (ReviewDecision::Denied, Some(PatchApplyStatus::Declined)) } @@ -1109,25 +1128,27 @@ async fn on_command_execution_request_approval_response( error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}"); CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Decline, - accept_settings: None, } }); - let CommandExecutionRequestApprovalResponse { - decision, - accept_settings, - } = response; + let decision = response.decision; - let (decision, completion_status) = match (decision, accept_settings) { - (ApprovalDecision::Accept, Some(settings)) if settings.for_session => { - (ReviewDecision::ApprovedForSession, None) - } - (ApprovalDecision::Accept, _) => (ReviewDecision::Approved, None), - (ApprovalDecision::Decline, _) => ( + let (decision, completion_status) = match decision { + ApprovalDecision::Accept => (ReviewDecision::Approved, None), + ApprovalDecision::AcceptForSession => (ReviewDecision::ApprovedForSession, None), + ApprovalDecision::AcceptWithExecpolicyAmendment { + execpolicy_amendment, + } => ( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: execpolicy_amendment.into_core(), + }, + None, + ), + ApprovalDecision::Decline => ( ReviewDecision::Denied, Some(CommandExecutionStatus::Declined), ), - (ApprovalDecision::Cancel, _) => ( + ApprovalDecision::Cancel => ( ReviewDecision::Abort, Some(CommandExecutionStatus::Declined), ), @@ -1189,7 +1210,7 @@ async fn construct_mcp_tool_call_notification( } } -/// simiilar to handle_mcp_tool_call_end in exec +/// similar to handle_mcp_tool_call_end in exec async fn construct_mcp_tool_call_end_notification( end_event: McpToolCallEndEvent, thread_id: String, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 65721a698ef..c62734f24ec 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -19,6 +19,7 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthStatusChangeNotification; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginAccountStatus; use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CommandExecParams; @@ -55,6 +56,9 @@ use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::McpServer; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginParams; +use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -115,6 +119,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::load_config_as_toml; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; @@ -132,6 +137,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::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; @@ -147,6 +153,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::perform_oauth_login_return_url; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::collections::HashSet; @@ -161,6 +168,7 @@ use std::time::Duration; use tokio::select; use tokio::sync::Mutex; use tokio::sync::oneshot; +use toml::Value as TomlValue; use tracing::error; use tracing::info; use tracing::warn; @@ -178,6 +186,9 @@ pub(crate) struct TurnSummary { pub(crate) type TurnSummaryStore = Arc>>; +const THREAD_LIST_DEFAULT_LIMIT: usize = 25; +const THREAD_LIST_MAX_LIMIT: usize = 100; + // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); struct ActiveLogin { @@ -185,6 +196,11 @@ struct ActiveLogin { login_id: Uuid, } +#[derive(Clone, Copy, Debug)] +enum CancelLoginError { + NotFound(Uuid), +} + impl Drop for ActiveLogin { fn drop(&mut self) { self.shutdown_handle.shutdown(); @@ -198,6 +214,7 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. @@ -244,6 +261,7 @@ impl CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, feedback: CodexFeedback, ) -> Self { Self { @@ -252,6 +270,7 @@ impl CodexMessageProcessor { outgoing, codex_linux_sandbox_exe, config, + cli_overrides, conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -261,6 +280,16 @@ impl CodexMessageProcessor { } } + async fn load_latest_config(&self) -> Result { + Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default()) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to reload config: {err}"), + data: None, + }) + } + fn review_request_from_target( target: ApiReviewTarget, ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { @@ -369,6 +398,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServerOauthLogin { request_id, params } => { + self.mcp_server_oauth_login(request_id, params).await; + } ClientRequest::McpServersList { request_id, params } => { self.list_mcp_servers(request_id, params).await; } @@ -802,7 +834,7 @@ impl CodexMessageProcessor { async fn cancel_login_chatgpt_common( &mut self, login_id: Uuid, - ) -> std::result::Result<(), JSONRPCErrorError> { + ) -> std::result::Result<(), CancelLoginError> { let mut guard = self.active_login.lock().await; if guard.as_ref().map(|l| l.login_id) == Some(login_id) { if let Some(active) = guard.take() { @@ -810,11 +842,7 @@ impl CodexMessageProcessor { } Ok(()) } else { - Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("login id not found: {login_id}"), - data: None, - }) + Err(CancelLoginError::NotFound(login_id)) } } @@ -825,7 +853,12 @@ impl CodexMessageProcessor { .send_response(request_id, CancelLoginChatGptResponse {}) .await; } - Err(error) => { + Err(CancelLoginError::NotFound(missing_login_id)) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("login id not found: {missing_login_id}"), + data: None, + }; self.outgoing.send_error(request_id, error).await; } } @@ -834,16 +867,14 @@ impl CodexMessageProcessor { async fn cancel_login_v2(&mut self, request_id: RequestId, params: CancelLoginAccountParams) { let login_id = params.login_id; match Uuid::parse_str(&login_id) { - Ok(uuid) => match self.cancel_login_chatgpt_common(uuid).await { - Ok(()) => { - self.outgoing - .send_response(request_id, CancelLoginAccountResponse {}) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - }, + Ok(uuid) => { + let status = match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => CancelLoginAccountStatus::Canceled, + Err(CancelLoginError::NotFound(_)) => CancelLoginAccountStatus::NotFound, + }; + let response = CancelLoginAccountResponse { status }; + self.outgoing.send_response(request_id, response).await; + } Err(_) => { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -1169,7 +1200,7 @@ impl CodexMessageProcessor { cwd, expiration: timeout_ms.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -1485,10 +1516,12 @@ impl CodexMessageProcessor { model_providers, } = params; - let page_size = limit.unwrap_or(25).max(1) as usize; - + let requested_page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); let (summaries, next_cursor) = match self - .list_conversations_common(page_size, cursor, model_providers) + .list_conversations_common(requested_page_size, cursor, model_providers) .await { Ok(r) => r, @@ -1499,7 +1532,6 @@ impl CodexMessageProcessor { }; let data = summaries.into_iter().map(summary_to_thread).collect(); - let response = ThreadListResponse { data, next_cursor }; self.outgoing.send_response(request_id, response).await; } @@ -1777,10 +1809,12 @@ impl CodexMessageProcessor { cursor, model_providers, } = params; - let page_size = page_size.unwrap_or(25).max(1); + let requested_page_size = page_size + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); match self - .list_conversations_common(page_size, cursor, model_providers) + .list_conversations_common(requested_page_size, cursor, model_providers) .await { Ok((items, next_cursor)) => { @@ -1795,12 +1829,15 @@ impl CodexMessageProcessor { async fn list_conversations_common( &self, - page_size: usize, + requested_page_size: usize, cursor: Option, model_providers: Option>, ) -> Result<(Vec, Option), JSONRPCErrorError> { - let cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); - let cursor_ref = cursor_obj.as_ref(); + let mut cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); + let mut last_cursor = cursor_obj.clone(); + let mut remaining = requested_page_size; + let mut items = Vec::with_capacity(requested_page_size); + let mut next_cursor: Option = None; let model_provider_filter = match model_providers { Some(providers) => { @@ -1814,55 +1851,76 @@ impl CodexMessageProcessor { }; let fallback_provider = self.config.model_provider_id.clone(); - let page = match RolloutRecorder::list_conversations( - &self.config.codex_home, - page_size, - cursor_ref, - INTERACTIVE_SESSION_SOURCES, - model_provider_filter.as_deref(), - fallback_provider.as_str(), - ) - .await - { - Ok(p) => p, - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to list conversations: {err}"), - data: None, - }); - } - }; - - let items = page - .items - .into_iter() - .filter_map(|it| { - let session_meta_line = it.head.first().and_then(|first| { - serde_json::from_value::(first.clone()).ok() - })?; - extract_conversation_summary( - it.path, - &it.head, - &session_meta_line.meta, - session_meta_line.git.as_ref(), - fallback_provider.as_str(), - ) - }) - .collect::>(); + while remaining > 0 { + let page_size = remaining.min(THREAD_LIST_MAX_LIMIT); + let page = RolloutRecorder::list_conversations( + &self.config.codex_home, + page_size, + cursor_obj.as_ref(), + INTERACTIVE_SESSION_SOURCES, + model_provider_filter.as_deref(), + fallback_provider.as_str(), + ) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to list conversations: {err}"), + data: None, + })?; - // Encode next_cursor as a plain string - let next_cursor = page - .next_cursor - .and_then(|cursor| serde_json::to_value(&cursor).ok()) - .and_then(|value| value.as_str().map(str::to_owned)); + let mut filtered = page + .items + .into_iter() + .filter_map(|it| { + let session_meta_line = it.head.first().and_then(|first| { + serde_json::from_value::(first.clone()).ok() + })?; + extract_conversation_summary( + it.path, + &it.head, + &session_meta_line.meta, + session_meta_line.git.as_ref(), + fallback_provider.as_str(), + ) + }) + .collect::>(); + if filtered.len() > remaining { + filtered.truncate(remaining); + } + items.extend(filtered); + remaining = requested_page_size.saturating_sub(items.len()); + + // Encode RolloutCursor into the JSON-RPC string form returned to clients. + let next_cursor_value = page.next_cursor.clone(); + next_cursor = next_cursor_value + .as_ref() + .and_then(|cursor| serde_json::to_value(cursor).ok()) + .and_then(|value| value.as_str().map(str::to_owned)); + if remaining == 0 { + break; + } + + match next_cursor_value { + Some(cursor_val) if remaining > 0 => { + // Break if our pagination would reuse the same cursor again; this avoids + // an infinite loop when filtering drops everything on the page. + if last_cursor.as_ref() == Some(&cursor_val) { + next_cursor = None; + break; + } + last_cursor = Some(cursor_val.clone()); + cursor_obj = Some(cursor_val); + } + _ => break, + } + } Ok((items, next_cursor)) } async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let models = supported_models(self.conversation_manager.clone()).await; + let models = supported_models(self.conversation_manager.clone(), &self.config).await; let total = models.len(); if total == 0 { @@ -1916,6 +1974,110 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn mcp_server_oauth_login( + &self, + request_id: RequestId, + params: McpServerOauthLoginParams, + ) { + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if !config.features.enabled(Feature::RmcpClient) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported when [features].rmcp_client is true in config.toml".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let McpServerOauthLoginParams { + name, + scopes, + timeout_secs, + } = params; + + let Some(server) = config.mcp_servers.get(&name) else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("No MCP server named '{name}' found."), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported for streamable HTTP servers." + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match perform_oauth_login_return_url( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + scopes.as_deref().unwrap_or_default(), + timeout_secs, + ) + .await + { + Ok(handle) => { + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); + + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + + let response = McpServerOauthLoginResponse { authorization_url }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to login to MCP server '{name}': {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; @@ -2669,7 +2831,7 @@ impl CodexMessageProcessor { })?; let mut config = self.config.as_ref().clone(); - config.model = self.config.review_model.clone(); + config.model = Some(self.config.review_model.clone()); let NewConversation { conversation_id, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ae02927f7af..c1eaf62d26f 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,6 +1,6 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use anyhow::anyhow; +use codex_app_server_protocol::Config; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -15,6 +15,8 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_core::config::ConfigToml; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::LoadedConfigLayers; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::load_config_layers_with_overrides; @@ -26,9 +28,8 @@ use sha2::Sha256; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use tempfile::NamedTempFile; -use tokio::task; use toml::Value as TomlValue; +use toml_edit::Item as TomlItem; const SESSION_FLAGS_SOURCE: &str = "--config"; const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; @@ -75,8 +76,10 @@ impl ConfigApi { let effective = layers.effective_config(); validate_config(&effective).map_err(|err| internal_error("invalid configuration", err))?; + let config: Config = serde_json::from_value(to_json_value(&effective)) + .map_err(|err| internal_error("failed to deserialize configuration", err))?; let response = ConfigReadResponse { - config: to_json_value(&effective), + config, origins: layers.origins(), layers: params.include_layers.then(|| layers.layers_high_to_low()), }; @@ -141,19 +144,20 @@ impl ConfigApi { } let mut user_config = layers.user.config.clone(); - let mut mutated = false; let mut parsed_segments = Vec::new(); + let mut config_edits = Vec::new(); for (key_path, value, strategy) in edits.into_iter() { let segments = parse_key_path(&key_path).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; + let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; - let changed = apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy) - .map_err(|err| match err { + apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( + |err| match err { MergeError::PathNotFound => config_write_error( ConfigWriteErrorCode::ConfigPathNotFound, "Path not found", @@ -161,9 +165,24 @@ impl ConfigApi { MergeError::Validation(message) => { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) } - })?; + }, + )?; + + let updated_value = value_at_path(&user_config, &segments).cloned(); + if original_value != updated_value { + let edit = match updated_value { + Some(value) => ConfigEdit::SetPath { + segments: segments.clone(), + value: toml_value_to_item(&value) + .map_err(|err| internal_error("failed to build config edits", err))?, + }, + None => ConfigEdit::ClearPath { + segments: segments.clone(), + }, + }; + config_edits.push(edit); + } - mutated |= changed; parsed_segments.push(segments); } @@ -183,8 +202,10 @@ impl ConfigApi { ) })?; - if mutated { - self.persist_user_config(&user_config) + if !config_edits.is_empty() { + ConfigEditsBuilder::new(&self.codex_home) + .with_edits(config_edits) + .apply() .await .map_err(|err| internal_error("failed to persist config.toml", err))?; } @@ -253,25 +274,6 @@ impl ConfigApi { mdm, }) } - - async fn persist_user_config(&self, user_config: &TomlValue) -> anyhow::Result<()> { - let codex_home = self.codex_home.clone(); - let serialized = toml::to_string_pretty(user_config)?; - - task::spawn_blocking(move || -> anyhow::Result<()> { - std::fs::create_dir_all(&codex_home)?; - - let target = codex_home.join(CONFIG_FILE_NAME); - let tmp = NamedTempFile::new_in(&codex_home)?; - std::fs::write(tmp.path(), serialized.as_bytes())?; - tmp.persist(&target)?; - Ok(()) - }) - .await - .map_err(|err| anyhow!("config persistence task panicked: {err}"))??; - - Ok(()) - } } fn parse_value(value: JsonValue) -> Result, String> { @@ -422,6 +424,44 @@ fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result anyhow::Result { + match value { + TomlValue::Table(table) => { + let mut table_item = toml_edit::Table::new(); + table_item.set_implicit(false); + for (key, val) in table { + table_item.insert(key, toml_value_to_item(val)?); + } + Ok(TomlItem::Table(table_item)) + } + other => Ok(TomlItem::Value(toml_value_to_value(other)?)), + } +} + +fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), + TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(toml_value_to_value(item)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, val) in table { + inline.insert(key, toml_value_to_value(val)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + #[derive(Clone)] struct LayerState { name: ConfigLayerName, @@ -735,9 +775,105 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::*; + use anyhow::Result; + use codex_app_server_protocol::AskForApproval; use pretty_assertions::assert_eq; use tempfile::tempdir; + #[test] + fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); + } + + #[tokio::test] + async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), original)?; + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + api.write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), + key_path: "features.remote_compaction".to_string(), + value: json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +remote_compaction = true +"#; + assert_eq!(updated, expected); + Ok(()) + } + #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); @@ -763,10 +899,7 @@ mod tests { .await .expect("response"); - assert_eq!( - response.config.get("approval_policy"), - Some(&json!("never")) - ); + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); assert_eq!( response @@ -821,8 +954,10 @@ mod tests { }) .await .expect("read"); - let config_object = read_after.config.as_object().expect("object"); - assert_eq!(config_object.get("approval_policy"), Some(&json!("never"))); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); assert_eq!( read_after .origins @@ -961,7 +1096,7 @@ mod tests { .await .expect("response"); - assert_eq!(response.config.get("model"), Some(&json!("system"))); + assert_eq!(response.config.model.as_deref(), Some("system")); assert_eq!( response.origins.get("model").expect("origin").name, ConfigLayerName::System diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 90560e9b3c5..6a6cf5edb25 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -59,6 +59,7 @@ impl MessageProcessor { outgoing.clone(), codex_linux_sandbox_exe, Arc::clone(&config), + cli_overrides.clone(), feedback, ); let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides); diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 3ac71e85b90..21411603547 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -3,12 +3,16 @@ use std::sync::Arc; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ConversationManager; +use codex_core::config::Config; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub async fn supported_models(conversation_manager: Arc) -> Vec { +pub async fn supported_models( + conversation_manager: Arc, + config: &Config, +) -> Vec { conversation_manager - .list_models() + .list_models(config) .await .into_iter() .map(model_from_preset) diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6fd54a66dc4..825b063c988 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,6 +1,7 @@ mod auth_fixtures; mod mcp_process; mod mock_model_server; +mod models_cache; mod responses; mod rollout; @@ -14,6 +15,8 @@ pub use core_test_support::format_with_current_shell_display; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; pub use mock_model_server::create_mock_chat_completions_server_unchecked; +pub use models_cache::write_models_cache; +pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs new file mode 100644 index 00000000000..8306e343941 --- /dev/null +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -0,0 +1,74 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_core::openai_models::model_presets::all_model_presets; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use serde_json::json; +use std::path::Path; + +/// Convert a ModelPreset to ModelInfo for cache storage. +fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { + ModelInfo { + slug: preset.id.clone(), + display_name: preset.display_name.clone(), + description: Some(preset.description.clone()), + default_reasoning_level: preset.default_reasoning_effort, + supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), + shell_type: ConfigShellToolType::ShellCommand, + visibility: if preset.show_in_picker { + ModelVisibility::List + } else { + ModelVisibility::Hide + }, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority, + upgrade: preset.upgrade.as_ref().map(|u| u.id.clone()), + base_instructions: None, + } +} + +/// Write a models_cache.json file to the codex home directory. +/// This prevents ModelsManager from making network requests to refresh models. +/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network. +/// Uses the built-in model presets from ModelsManager, converted to ModelInfo format. +pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> { + // Get all presets and filter for show_in_picker (same as builtin_model_presets does) + let presets: Vec<&ModelPreset> = all_model_presets() + .iter() + .filter(|preset| preset.show_in_picker) + .collect(); + // Convert presets to ModelInfo, assigning priorities (higher = earlier in list) + // Priority is used for sorting, so first model gets highest priority + let models: Vec = presets + .iter() + .enumerate() + .map(|(idx, preset)| { + // Higher priority = earlier in list, so reverse the index + let priority = (presets.len() - idx) as i32; + preset_to_info(preset, priority) + }) + .collect(); + + write_models_cache_with_models(codex_home, models) +} + +/// Write a models_cache.json file with specific models. +/// Useful when tests need specific models to be available. +pub fn write_models_cache_with_models( + codex_home: &Path, + models: Vec, +) -> std::io::Result<()> { + let cache_path = codex_home.join("models_cache.json"); + // DateTime serializes to RFC3339 format by default with serde + let fetched_at: DateTime = Utc::now(); + let cache = json!({ + "fetched_at": fetched_at, + "etag": null, + "models": models + }); + std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?) +} diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 4b206436c86..e417198994d 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -271,7 +271,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { command: format_with_current_shell("python3 -c 'print(42)'"), cwd: working_directory.clone(), reason: None, - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 1e89c068484..34e737437ca 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -358,3 +358,81 @@ async fn test_list_and_resume_conversations() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_conversations_fetches_through_filtered_pages() -> Result<()> { + let codex_home = TempDir::new()?; + + // Only the last 3 conversations match the provider filter; request 3 and + // ensure pagination keeps fetching past non-matching pages. + let cases = [ + ( + "2025-03-04T12-00-00", + "2025-03-04T12:00:00Z", + "skip_provider", + ), + ( + "2025-03-03T12-00-00", + "2025-03-03T12:00:00Z", + "skip_provider", + ), + ( + "2025-03-02T12-00-00", + "2025-03-02T12:00:00Z", + "target_provider", + ), + ( + "2025-03-01T12-00-00", + "2025-03-01T12:00:00Z", + "target_provider", + ), + ( + "2025-02-28T12-00-00", + "2025-02-28T12:00:00Z", + "target_provider", + ), + ]; + + for (ts_file, ts_rfc, provider) in cases { + create_fake_rollout( + codex_home.path(), + ts_file, + ts_rfc, + "Hello", + Some(provider), + None, + )?; + } + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(3), + cursor: None, + model_providers: Some(vec!["target_provider".to_string()]), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ListConversationsResponse { items, next_cursor } = + to_response::(resp)?; + + assert_eq!( + items.len(), + 3, + "should fetch across pages to satisfy the limit" + ); + assert!( + items + .iter() + .all(|item| item.model_provider == "target_provider") + ); + assert_eq!(next_cursor, None); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index c5470c3ec4d..e252bcb0c09 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -1,8 +1,6 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; -use codex_app_server_protocol::CancelLoginChatGptParams; -use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -14,7 +12,6 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; use serial_test::serial; use std::path::Path; -use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -87,48 +84,6 @@ async fn logout_chatgpt_removes_auth() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// Serialize tests that launch the login server since it binds to a fixed port. -#[serial(login_port)] -async fn login_and_cancel_chatgpt() -> Result<()> { - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let login_id = mcp.send_login_chat_gpt_request().await?; - let login_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(login_id)), - ) - .await??; - let login: LoginChatGptResponse = to_response(login_resp)?; - - let cancel_id = mcp - .send_cancel_login_chat_gpt_request(CancelLoginChatGptParams { - login_id: login.login_id, - }) - .await?; - let cancel_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), - ) - .await??; - let _ok: CancelLoginChatGptResponse = to_response(cancel_resp)?; - - // Optionally observe the completion notification; do not fail if it races. - let maybe_note = timeout( - Duration::from_secs(2), - mcp.read_stream_until_notification_message("codex/event/login_chat_gpt_complete"), - ) - .await; - if maybe_note.is_err() { - eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel"); - } - Ok(()) -} - fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); let contents = format!( diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index dd592707392..4d481f3959d 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -241,7 +241,7 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { #[tokio::test] // Serialize tests that launch the login server since it binds to a fixed port. #[serial(login_port)] -async fn login_account_chatgpt_start() -> Result<()> { +async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; 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 eb3ece64b29..b6615ef6679 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::ConfigLayerName; @@ -12,9 +13,12 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; 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 pretty_assertions::assert_eq; use serde_json::json; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +61,7 @@ sandbox_mode = "workspace-write" layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-user"))); + assert_eq!(config.model.as_deref(), Some("gpt-user")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::User @@ -70,6 +74,64 @@ sandbox_mode = "workspace-write" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_tools() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" + +[tools] +web_search = true +view_image = false +"#, + )?; + + 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, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + let tools = config.tools.expect("tools present"); + assert_eq!( + tools, + ToolsV2 { + web_search: Some(true), + view_image: Some(false), + } + ); + assert_eq!( + origins.get("tools.web_search").expect("origin").name, + ConfigLayerName::User + ); + assert_eq!( + origins.get("tools.view_image").expect("origin").name, + ConfigLayerName::User + ); + + let layers = layers.expect("layers present"); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); + assert_eq!(layers[1].name, ConfigLayerName::User); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; @@ -123,30 +185,29 @@ writable_roots = ["/system"] layers, } = to_response(resp)?; - assert_eq!(config.get("model"), Some(&json!("gpt-system"))); + assert_eq!(config.model.as_deref(), Some("gpt-system")); assert_eq!( origins.get("model").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("approval_policy"), Some(&json!("never"))); + assert_eq!(config.approval_policy, Some(AskForApproval::Never)); assert_eq!( origins.get("approval_policy").expect("origin").name, ConfigLayerName::System ); - assert_eq!(config.get("sandbox_mode"), Some(&json!("workspace-write"))); + assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); assert_eq!( origins.get("sandbox_mode").expect("origin").name, ConfigLayerName::User ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/system"])) - ); + let sandbox = config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/system")]); assert_eq!( origins .get("sandbox_workspace_write.writable_roots.0") @@ -155,12 +216,7 @@ writable_roots = ["/system"] ConfigLayerName::System ); - assert_eq!( - config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(true)) - ); + assert!(sandbox.network_access); assert_eq!( origins .get("sandbox_workspace_write.network_access") @@ -242,7 +298,7 @@ model = "gpt-old" ) .await??; let verify: ConfigReadResponse = to_response(verify_resp)?; - assert_eq!(verify.config.get("model"), Some(&json!("gpt-new"))); + assert_eq!(verify.config.model.as_deref(), Some("gpt-new")); Ok(()) } @@ -342,22 +398,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { ) .await??; let read: ConfigReadResponse = to_response(read_resp)?; - assert_eq!( - read.config.get("sandbox_mode"), - Some(&json!("workspace-write")) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("writable_roots")), - Some(&json!(["/tmp"])) - ); - assert_eq!( - read.config - .get("sandbox_workspace_write") - .and_then(|v| v.get("network_access")), - Some(&json!(false)) - ); + assert_eq!(read.config.sandbox_mode, Some(SandboxMode::WorkspaceWrite)); + let sandbox = read + .config + .sandbox_workspace_write + .as_ref() + .expect("sandbox workspace write"); + assert_eq!(sandbox.writable_roots, vec![PathBuf::from("/tmp")]); + assert!(!sandbox.network_access); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 8ca85c9c3b9..e8d3d160581 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -4,6 +4,7 @@ use anyhow::Result; use anyhow::anyhow; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_models_cache; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; @@ -22,6 +23,7 @@ const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -114,6 +116,37 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { default_reasoning_effort: ReasoningEffort::Medium, is_default: false, }, + Model { + id: "robin".to_string(), + model: "robin".to_string(), + display_name: "robin".to_string(), + description: "Robin".to_string(), + supported_reasoning_efforts: vec![ + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Low, + description: "Balances speed with some reasoning; useful for straightforward \ + queries and short explanations" + .to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Medium, + description: "Provides a solid balance of reasoning depth and latency for \ + general-purpose tasks" + .to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::XHigh, + description: "Extra high reasoning for complex problems".to_string(), + }, + ], + default_reasoning_effort: ReasoningEffort::Medium, + is_default: false, + }, Model { id: "gpt-5.1".to_string(), model: "gpt-5.1".to_string(), @@ -151,6 +184,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { #[tokio::test] async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -240,14 +274,37 @@ async fn list_models_pagination_works() -> Result<()> { } = to_response::(fourth_response)?; assert_eq!(fourth_items.len(), 1); - assert_eq!(fourth_items[0].id, "gpt-5.1"); - assert!(fourth_cursor.is_none()); + assert_eq!(fourth_items[0].id, "robin"); + let fifth_cursor = fourth_cursor.ok_or_else(|| anyhow!("cursor for fifth page"))?; + + let fifth_request = mcp + .send_list_models_request(ModelListParams { + limit: Some(1), + cursor: Some(fifth_cursor.clone()), + }) + .await?; + + let fifth_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fifth_request)), + ) + .await??; + + let ModelListResponse { + data: fifth_items, + next_cursor: fifth_cursor, + } = to_response::(fifth_response)?; + + assert_eq!(fifth_items.len(), 1); + assert_eq!(fifth_items[0].id, "gpt-5.1"); + assert!(fifth_cursor.is_none()); Ok(()) } #[tokio::test] async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; + write_models_cache(codex_home.path())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 57299ef97e2..0132651df82 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -6,37 +6,96 @@ use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; -use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -#[tokio::test] -async fn thread_list_basic_empty() -> Result<()> { - let codex_home = TempDir::new()?; - create_minimal_config(codex_home.path())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; +async fn init_mcp(codex_home: &Path) -> Result { + let mut mcp = McpProcess::new(codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(mcp) +} - // List threads in an empty CODEX_HOME; should return an empty page with nextCursor: null. - let list_id = mcp - .send_thread_list_request(ThreadListParams { - cursor: None, - limit: Some(10), - model_providers: Some(vec!["mock_provider".to_string()]), +async fn list_threads( + mcp: &mut McpProcess, + cursor: Option, + limit: Option, + providers: Option>, +) -> Result { + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor, + limit, + model_providers: providers, }) .await?; - let list_resp: JSONRPCResponse = timeout( + let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let ThreadListResponse { data, next_cursor } = to_response::(list_resp)?; + to_response::(resp) +} + +fn create_fake_rollouts( + codex_home: &Path, + count: usize, + provider_for_index: F, + timestamp_for_index: G, + preview: &str, +) -> Result> +where + F: Fn(usize) -> &'static str, + G: Fn(usize) -> (String, String), +{ + let mut ids = Vec::with_capacity(count); + for i in 0..count { + let (ts_file, ts_rfc) = timestamp_for_index(i); + ids.push(create_fake_rollout( + codex_home, + &ts_file, + &ts_rfc, + preview, + Some(provider_for_index(i)), + None, + )?); + } + Ok(ids) +} + +fn timestamp_at( + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, +) -> (String, String) { + ( + format!("{year:04}-{month:02}-{day:02}T{hour:02}-{minute:02}-{second:02}"), + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"), + ) +} + +#[tokio::test] +async fn thread_list_basic_empty() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), + ) + .await?; assert!(data.is_empty()); assert_eq!(next_cursor, None); @@ -86,26 +145,19 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let mut mcp = init_mcp(codex_home.path()).await?; // Page 1: limit 2 → expect next_cursor Some. - let page1_id = mcp - .send_thread_list_request(ThreadListParams { - cursor: None, - limit: Some(2), - model_providers: Some(vec!["mock_provider".to_string()]), - }) - .await?; - let page1_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(page1_id)), - ) - .await??; let ThreadListResponse { data: data1, next_cursor: cursor1, - } = to_response::(page1_resp)?; + } = list_threads( + &mut mcp, + None, + Some(2), + Some(vec!["mock_provider".to_string()]), + ) + .await?; assert_eq!(data1.len(), 2); for thread in &data1 { assert_eq!(thread.preview, "Hello"); @@ -119,22 +171,16 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { let cursor1 = cursor1.expect("expected nextCursor on first page"); // Page 2: with cursor → expect next_cursor None when no more results. - let page2_id = mcp - .send_thread_list_request(ThreadListParams { - cursor: Some(cursor1), - limit: Some(2), - model_providers: Some(vec!["mock_provider".to_string()]), - }) - .await?; - let page2_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(page2_id)), - ) - .await??; let ThreadListResponse { data: data2, next_cursor: cursor2, - } = to_response::(page2_resp)?; + } = list_threads( + &mut mcp, + Some(cursor1), + Some(2), + Some(vec!["mock_provider".to_string()]), + ) + .await?; assert!(data2.len() <= 2); for thread in &data2 { assert_eq!(thread.preview, "Hello"); @@ -173,23 +219,16 @@ async fn thread_list_respects_provider_filter() -> Result<()> { None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let mut mcp = init_mcp(codex_home.path()).await?; // Filter to only other_provider; expect 1 item, nextCursor None. - let list_id = mcp - .send_thread_list_request(ThreadListParams { - cursor: None, - limit: Some(10), - model_providers: Some(vec!["other_provider".to_string()]), - }) - .await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["other_provider".to_string()]), ) - .await??; - let ThreadListResponse { data, next_cursor } = to_response::(resp)?; + .await?; assert_eq!(data.len(), 1); assert_eq!(next_cursor, None); let thread = &data[0]; @@ -205,6 +244,146 @@ async fn thread_list_respects_provider_filter() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Newest 16 conversations belong to a different provider; the older 8 are the + // only ones that match the filter. We request 8 so the server must keep + // paging past the first two pages to reach the desired count. + create_fake_rollouts( + codex_home.path(), + 24, + |i| { + if i < 16 { + "skip_provider" + } else { + "target_provider" + } + }, + |i| timestamp_at(2025, 3, 30 - i as u32, 12, 0, 0), + "Hello", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + // Request 8 threads for the target provider; the matches only start on the + // third page so we rely on pagination to reach the limit. + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(8), + Some(vec!["target_provider".to_string()]), + ) + .await?; + assert_eq!( + data.len(), + 8, + "should keep paging until the requested count is filled" + ); + assert!( + data.iter() + .all(|thread| thread.model_provider == "target_provider"), + "all returned threads must match the requested provider" + ); + assert_eq!( + next_cursor, None, + "once the requested count is satisfied on the final page, nextCursor should be None" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_enforces_max_limit() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + create_fake_rollouts( + codex_home.path(), + 105, + |_| "mock_provider", + |i| { + let month = 5 + (i / 28); + let day = (i % 28) + 1; + timestamp_at(2025, month as u32, day as u32, 0, 0, 0) + }, + "Hello", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(200), + Some(vec!["mock_provider".to_string()]), + ) + .await?; + assert_eq!( + data.len(), + 100, + "limit should be clamped to the maximum page size" + ); + assert!( + next_cursor.is_some(), + "when more than the maximum exist, nextCursor should continue pagination" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Only the last 7 conversations match the provider filter; we ask for 10 to + // ensure the server exhausts pagination without looping forever. + create_fake_rollouts( + codex_home.path(), + 22, + |i| { + if i < 15 { + "skip_provider" + } else { + "target_provider" + } + }, + |i| timestamp_at(2025, 4, 28 - i as u32, 8, 0, 0), + "Hello", + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + + // Request more threads than exist after filtering; expect all matches to be + // returned with nextCursor None. + let ThreadListResponse { data, next_cursor } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["target_provider".to_string()]), + ) + .await?; + assert_eq!( + data.len(), + 7, + "all available filtered threads should be returned" + ); + assert!( + data.iter() + .all(|thread| thread.model_provider == "target_provider"), + "results should still respect the provider filter" + ); + assert_eq!( + next_cursor, None, + "when results are exhausted before reaching the limit, nextCursor should be None" + ); + + Ok(()) +} + #[tokio::test] async fn thread_list_includes_git_info() -> Result<()> { let codex_home = TempDir::new()?; @@ -224,22 +403,15 @@ async fn thread_list_includes_git_info() -> Result<()> { Some(git_info), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let mut mcp = init_mcp(codex_home.path()).await?; - let list_id = mcp - .send_thread_list_request(ThreadListParams { - cursor: None, - limit: Some(10), - model_providers: Some(vec!["mock_provider".to_string()]), - }) - .await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + let ThreadListResponse { data, .. } = list_threads( + &mut mcp, + None, + Some(10), + Some(vec!["mock_provider".to_string()]), ) - .await??; - let ThreadListResponse { data, .. } = to_response::(resp)?; + .await?; let thread = data .iter() .find(|t| t.id == conversation_id) 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 e4cd7229474..afc22c70720 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -427,7 +427,6 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { request_id, serde_json::to_value(CommandExecutionRequestApprovalResponse { decision: ApprovalDecision::Decline, - accept_settings: None, })?, ) .await?; diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 867d19a2e8d..fe4fe584dc9 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -112,7 +112,7 @@ fn classify_shell_name(shell: &str) -> Option { fn classify_shell(shell: &str, flag: &str) -> Option { classify_shell_name(shell).and_then(|name| match name.as_str() { - "bash" | "zsh" | "sh" if flag == "-lc" => Some(ApplyPatchShell::Unix), + "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix), "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => { Some(ApplyPatchShell::PowerShell) } @@ -699,13 +699,7 @@ fn derive_new_contents_from_chunks( } }; - let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); - - // Drop the trailing empty element that results from the final newline so - // that line counts match the behaviour of standard `diff`. - if original_lines.last().is_some_and(String::is_empty) { - original_lines.pop(); - } + let original_lines: Vec = build_lines_from_contents(&original_contents); let replacements = compute_replacements(&original_lines, path, chunks)?; let new_lines = apply_replacements(original_lines, &replacements); @@ -713,13 +707,67 @@ fn derive_new_contents_from_chunks( if !new_lines.last().is_some_and(String::is_empty) { new_lines.push(String::new()); } - let new_contents = new_lines.join("\n"); + let new_contents = build_contents_from_lines(&original_contents, &new_lines); Ok(AppliedPatch { original_contents, new_contents, }) } +// TODO(dylan-hurd-oai): I think we can migrate to just use `contents.lines()` +// across all platforms. +fn build_lines_from_contents(contents: &str) -> Vec { + if cfg!(windows) { + contents.lines().map(String::from).collect() + } else { + let mut lines: Vec = contents.split('\n').map(String::from).collect(); + + // Drop the trailing empty element that results from the final newline so + // that line counts match the behaviour of standard `diff`. + if lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + + lines + } +} + +fn build_contents_from_lines(original_contents: &str, lines: &[String]) -> String { + if cfg!(windows) { + // for now, only compute this if we're on Windows. + let uses_crlf = contents_uses_crlf(original_contents); + if uses_crlf { + lines.join("\r\n") + } else { + lines.join("\n") + } + } else { + lines.join("\n") + } +} + +/// Detects whether the source file uses Windows CRLF line endings consistently. +/// We only consider a file CRLF-formatted if every newline is part of a +/// CRLF sequence. This avoids rewriting an LF-formatted file that merely +/// contains embedded sequences of "\r\n". +/// +/// Returns `true` if the file uses CRLF line endings, `false` otherwise. +fn contents_uses_crlf(contents: &str) -> bool { + let bytes = contents.as_bytes(); + let mut n_newlines = 0usize; + let mut n_crlf = 0usize; + for i in 0..bytes.len() { + if bytes[i] == b'\n' { + n_newlines += 1; + if i > 0 && bytes[i - 1] == b'\r' { + n_crlf += 1; + } + } + } + + n_newlines > 0 && n_crlf == n_newlines +} + /// Compute a list of replacements needed to transform `original_lines` into the /// new lines, given the patch `chunks`. Each replacement is returned as /// `(start_index, old_len, new_lines)`. @@ -1049,6 +1097,13 @@ mod tests { assert_match(&heredoc_script(""), None); } + #[test] + fn test_heredoc_non_login_shell() { + let script = heredoc_script(""); + let args = strs_to_strings(&["bash", "-c", &script]); + assert_match_args(args, None); + } + #[test] fn test_heredoc_applypatch() { let args = strs_to_strings(&[ @@ -1359,6 +1414,72 @@ PATCH"#, assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); } + /// Ensure CRLF line endings are preserved for updated files on Windows‑style inputs. + #[cfg(windows)] + #[test] + fn test_preserve_crlf_line_endings_on_update() { + let dir = tempdir().unwrap(); + let path = dir.path().join("crlf.txt"); + + // Original file uses CRLF (\r\n) endings. + std::fs::write(&path, b"a\r\nb\r\nc\r\n").unwrap(); + + // Replace `b` -> `B` and append `d`. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + a +-b ++B +@@ + c ++d +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let out = std::fs::read(&path).unwrap(); + // Expect all CRLF endings; count occurrences of CRLF and ensure there are 4 lines. + let content = String::from_utf8_lossy(&out); + assert!(content.contains("\r\n")); + // No bare LF occurrences immediately preceding a non-CR: the text should not contain "a\nb". + assert!(!content.contains("a\nb")); + // Validate exact content sequence with CRLF delimiters. + assert_eq!(content, "a\r\nB\r\nc\r\nd\r\n"); + } + + /// Ensure CRLF inputs with embedded carriage returns in the content are preserved. + #[cfg(windows)] + #[test] + fn test_preserve_crlf_embedded_carriage_returns_on_append() { + let dir = tempdir().unwrap(); + let path = dir.path().join("crlf_cr_content.txt"); + + // Original file: first line has a literal '\r' in the content before the CRLF terminator. + std::fs::write(&path, b"foo\r\r\nbar\r\n").unwrap(); + + // Append a new line without modifying existing ones. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++BAZ +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let out = std::fs::read(&path).unwrap(); + // CRLF endings must be preserved and the extra CR in "foo\r\r" must not be collapsed. + assert_eq!(out.as_slice(), b"foo\r\r\nbar\r\nBAZ\r\n"); + } + #[test] fn test_pure_addition_chunk_followed_by_removal() { let dir = tempdir().unwrap(); @@ -1544,6 +1665,37 @@ PATCH"#, assert_eq!(expected, diff); } + /// For LF-only inputs with a trailing newline ensure that the helper used + /// on Windows-style builds drops the synthetic trailing empty element so + /// replacements behave like standard `diff` line numbering. + #[test] + fn test_derive_new_contents_lf_trailing_newline() { + let dir = tempdir().unwrap(); + let path = dir.path().join("lf_trailing_newline.txt"); + fs::write(&path, "foo\nbar\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo +-bar ++BAR +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let AppliedPatch { new_contents, .. } = + derive_new_contents_from_chunks(&path, chunks).unwrap(); + + assert_eq!(new_contents, "foo\nBAR\n"); + } + #[test] fn test_unified_diff_insert_at_eof() { // Insert a new line at end‑of‑file. diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 6c80a12595e..84e6e9acaf4 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ 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 } ctor = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6cff73e86de..113c6a75153 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -25,6 +25,7 @@ use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::update_action::UpdateAction; +use codex_tui2 as tui2; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; @@ -37,6 +38,11 @@ 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; /// Codex CLI @@ -444,7 +450,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { @@ -499,7 +505,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() all, config_overrides, ); - let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; + let exit_info = run_interactive_tui(interactive, codex_linux_sandbox_exe).await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { @@ -650,6 +656,40 @@ 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, + 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 + } +} + +/// 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))?; + + let codex_home = find_codex_home()?; + let config_toml = load_config_as_toml_with_cli_overrides(&codex_home, 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)) +} + /// Build the final `TuiCli` for a `codex resume` invocation. fn finalize_resume_interactive( mut interactive: TuiCli, diff --git a/codex-rs/cloud-tasks-client/src/api.rs b/codex-rs/cloud-tasks-client/src/api.rs index 4bd12939e84..cd8228bc280 100644 --- a/codex-rs/cloud-tasks-client/src/api.rs +++ b/codex-rs/cloud-tasks-client/src/api.rs @@ -127,6 +127,7 @@ impl Default for TaskText { #[async_trait::async_trait] pub trait CloudBackend: Send + Sync { async fn list_tasks(&self, env: Option<&str>) -> Result>; + async fn get_task_summary(&self, id: TaskId) -> Result; async fn get_task_diff(&self, id: TaskId) -> Result>; /// Return assistant output messages (no diff) when available. async fn get_task_messages(&self, id: TaskId) -> Result>; diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 57d39b7bda8..f55d0fe7971 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -63,6 +63,10 @@ impl CloudBackend for HttpClient { self.tasks_api().list(env).await } + async fn get_task_summary(&self, id: TaskId) -> Result { + self.tasks_api().summary(id).await + } + async fn get_task_diff(&self, id: TaskId) -> Result> { self.tasks_api().diff(id).await } @@ -149,6 +153,75 @@ mod api { Ok(tasks) } + pub(crate) async fn summary(&self, id: TaskId) -> Result { + let id_str = id.0.clone(); + let (details, body, ct) = self + .details_with_body(&id.0) + .await + .map_err(|e| CloudTaskError::Http(format!("get_task_details failed: {e}")))?; + let parsed: Value = serde_json::from_str(&body).map_err(|e| { + CloudTaskError::Http(format!( + "Decode error for {}: {e}; content-type={ct}; body={body}", + id.0 + )) + })?; + let task_obj = parsed + .get("task") + .and_then(Value::as_object) + .ok_or_else(|| { + CloudTaskError::Http(format!("Task metadata missing from details for {id_str}")) + })?; + let status_display = parsed + .get("task_status_display") + .or_else(|| task_obj.get("task_status_display")) + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::>() + }); + let status = map_status(status_display.as_ref()); + let mut summary = diff_summary_from_status_display(status_display.as_ref()); + if summary.files_changed == 0 + && summary.lines_added == 0 + && summary.lines_removed == 0 + && let Some(diff) = details.unified_diff() + { + summary = diff_summary_from_diff(&diff); + } + let updated_at_raw = task_obj + .get("updated_at") + .and_then(Value::as_f64) + .or_else(|| task_obj.get("created_at").and_then(Value::as_f64)) + .or_else(|| latest_turn_timestamp(status_display.as_ref())); + let environment_id = task_obj + .get("environment_id") + .and_then(Value::as_str) + .map(str::to_string); + let environment_label = env_label_from_status_display(status_display.as_ref()); + let attempt_total = attempt_total_from_status_display(status_display.as_ref()); + let title = task_obj + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let is_review = task_obj + .get("is_review") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(TaskSummary { + id, + title, + status, + updated_at: parse_updated_at(updated_at_raw.as_ref()), + environment_id, + environment_label, + summary, + is_review, + attempt_total, + }) + } + pub(crate) async fn diff(&self, id: TaskId) -> Result> { let (details, body, ct) = self .details_with_body(&id.0) @@ -679,6 +752,34 @@ mod api { .map(str::to_string) } + fn diff_summary_from_diff(diff: &str) -> DiffSummary { + let mut files_changed = 0usize; + let mut lines_added = 0usize; + let mut lines_removed = 0usize; + for line in diff.lines() { + if line.starts_with("diff --git ") { + files_changed += 1; + continue; + } + if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") { + continue; + } + match line.as_bytes().first() { + Some(b'+') => lines_added += 1, + Some(b'-') => lines_removed += 1, + _ => {} + } + } + if files_changed == 0 && !diff.trim().is_empty() { + files_changed = 1; + } + DiffSummary { + files_changed, + lines_added, + lines_removed, + } + } + fn diff_summary_from_status_display(v: Option<&HashMap>) -> DiffSummary { let mut out = DiffSummary::default(); let Some(map) = v else { return out }; @@ -700,6 +801,17 @@ mod api { out } + fn latest_turn_timestamp(v: Option<&HashMap>) -> Option { + let map = v?; + let latest = map + .get("latest_turn_status_display") + .and_then(Value::as_object)?; + latest + .get("updated_at") + .or_else(|| latest.get("created_at")) + .and_then(Value::as_f64) + } + fn attempt_total_from_status_display(v: Option<&HashMap>) -> Option { let map = v?; let latest = map diff --git a/codex-rs/cloud-tasks-client/src/mock.rs b/codex-rs/cloud-tasks-client/src/mock.rs index 97bc5520a83..2d03cea029f 100644 --- a/codex-rs/cloud-tasks-client/src/mock.rs +++ b/codex-rs/cloud-tasks-client/src/mock.rs @@ -1,6 +1,7 @@ use crate::ApplyOutcome; use crate::AttemptStatus; use crate::CloudBackend; +use crate::CloudTaskError; use crate::DiffSummary; use crate::Result; use crate::TaskId; @@ -60,6 +61,14 @@ impl CloudBackend for MockClient { Ok(out) } + async fn get_task_summary(&self, id: TaskId) -> Result { + let tasks = self.list_tasks(None).await?; + tasks + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found (mock)", id.0))) + } + async fn get_task_diff(&self, id: TaskId) -> Result> { Ok(Some(mock_diff_for(&id))) } diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index c9edf5b4ad5..188538bec68 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -34,6 +34,9 @@ tokio-stream = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } unicode-width = { workspace = true } +owo-colors = { workspace = true, features = ["supports-colors"] } +supports-color = { workspace = true } [dev-dependencies] async-trait = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/app.rs b/codex-rs/cloud-tasks/src/app.rs index 612c5f6be4b..ce12128a3ea 100644 --- a/codex-rs/cloud-tasks/src/app.rs +++ b/codex-rs/cloud-tasks/src/app.rs @@ -350,6 +350,7 @@ pub enum AppEvent { mod tests { use super::*; use chrono::Utc; + use codex_cloud_tasks_client::CloudTaskError; struct FakeBackend { // maps env key to titles @@ -385,6 +386,17 @@ mod tests { Ok(out) } + async fn get_task_summary( + &self, + id: TaskId, + ) -> codex_cloud_tasks_client::Result { + self.list_tasks(None) + .await? + .into_iter() + .find(|t| t.id == id) + .ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0))) + } + async fn get_task_diff( &self, _id: TaskId, diff --git a/codex-rs/cloud-tasks/src/cli.rs b/codex-rs/cloud-tasks/src/cli.rs index 9c118038eb1..a7612153b4e 100644 --- a/codex-rs/cloud-tasks/src/cli.rs +++ b/codex-rs/cloud-tasks/src/cli.rs @@ -16,6 +16,12 @@ pub struct Cli { pub enum Command { /// Submit a new Codex Cloud task without launching the TUI. Exec(ExecCommand), + /// Show the status of a Codex Cloud task. + Status(StatusCommand), + /// Apply the diff for a Codex Cloud task locally. + Apply(ApplyCommand), + /// Show the unified diff for a Codex Cloud task. + Diff(DiffCommand), } #[derive(Debug, Args)] @@ -51,3 +57,32 @@ fn parse_attempts(input: &str) -> Result { Err("attempts must be between 1 and 4".to_string()) } } + +#[derive(Debug, Args)] +pub struct StatusCommand { + /// Codex Cloud task identifier to inspect. + #[arg(value_name = "TASK_ID")] + pub task_id: String, +} + +#[derive(Debug, Args)] +pub struct ApplyCommand { + /// Codex Cloud task identifier to apply. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to apply (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} + +#[derive(Debug, Args)] +pub struct DiffCommand { + /// Codex Cloud task identifier to display. + #[arg(value_name = "TASK_ID")] + pub task_id: String, + + /// Attempt number to display (1-based). + #[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")] + pub attempt: Option, +} diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 1a3798f7580..f73e07f3afb 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -8,17 +8,24 @@ pub mod util; pub use cli::Cli; use anyhow::anyhow; +use chrono::Utc; +use codex_cloud_tasks_client::TaskStatus; use codex_login::AuthManager; +use owo_colors::OwoColorize; +use owo_colors::Stream; +use std::cmp::Ordering; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use supports_color::Stream as SupportStream; use tokio::sync::mpsc::UnboundedSender; use tracing::info; use tracing_subscriber::EnvFilter; use util::append_error_log; +use util::format_relative_time; use util::set_user_agent_suffix; struct ApplyJob { @@ -193,6 +200,273 @@ fn resolve_query_input(query_arg: Option) -> anyhow::Result { } } +fn parse_task_id(raw: &str) -> anyhow::Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + anyhow::bail!("task id must not be empty"); + } + let without_fragment = trimmed.split('#').next().unwrap_or(trimmed); + let without_query = without_fragment + .split('?') + .next() + .unwrap_or(without_fragment); + let id = without_query + .rsplit('/') + .next() + .unwrap_or(without_query) + .trim(); + if id.is_empty() { + anyhow::bail!("task id must not be empty"); + } + Ok(codex_cloud_tasks_client::TaskId(id.to_string())) +} + +#[derive(Clone, Debug)] +struct AttemptDiffData { + placement: Option, + created_at: Option>, + diff: String, +} + +fn cmp_attempt(lhs: &AttemptDiffData, rhs: &AttemptDiffData) -> Ordering { + match (lhs.placement, rhs.placement) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => match (lhs.created_at, rhs.created_at) { + (Some(a), Some(b)) => a.cmp(&b), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + }, + } +} + +async fn collect_attempt_diffs( + backend: &dyn codex_cloud_tasks_client::CloudBackend, + task_id: &codex_cloud_tasks_client::TaskId, +) -> anyhow::Result> { + let text = + codex_cloud_tasks_client::CloudBackend::get_task_text(backend, task_id.clone()).await?; + let mut attempts = Vec::new(); + if let Some(diff) = + codex_cloud_tasks_client::CloudBackend::get_task_diff(backend, task_id.clone()).await? + { + attempts.push(AttemptDiffData { + placement: text.attempt_placement, + created_at: None, + diff, + }); + } + if let Some(turn_id) = text.turn_id { + let siblings = codex_cloud_tasks_client::CloudBackend::list_sibling_attempts( + backend, + task_id.clone(), + turn_id, + ) + .await?; + for sibling in siblings { + if let Some(diff) = sibling.diff { + attempts.push(AttemptDiffData { + placement: sibling.attempt_placement, + created_at: sibling.created_at, + diff, + }); + } + } + } + attempts.sort_by(cmp_attempt); + if attempts.is_empty() { + anyhow::bail!( + "No diff available for task {}; it may still be running.", + task_id.0 + ); + } + Ok(attempts) +} + +fn select_attempt( + attempts: &[AttemptDiffData], + attempt: Option, +) -> anyhow::Result<&AttemptDiffData> { + if attempts.is_empty() { + anyhow::bail!("No attempts available"); + } + let desired = attempt.unwrap_or(1); + let idx = desired + .checked_sub(1) + .ok_or_else(|| anyhow!("attempt must be at least 1"))?; + if idx >= attempts.len() { + anyhow::bail!( + "Attempt {desired} not available; only {} attempt(s) found", + attempts.len() + ); + } + Ok(&attempts[idx]) +} + +fn task_status_label(status: &TaskStatus) -> &'static str { + match status { + TaskStatus::Pending => "PENDING", + TaskStatus::Ready => "READY", + TaskStatus::Applied => "APPLIED", + TaskStatus::Error => "ERROR", + } +} + +fn summary_line(summary: &codex_cloud_tasks_client::DiffSummary, colorize: bool) -> String { + if summary.files_changed == 0 && summary.lines_added == 0 && summary.lines_removed == 0 { + let base = "no diff"; + return if colorize { + base.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + base.to_string() + }; + } + let adds = summary.lines_added; + let dels = summary.lines_removed; + let files = summary.files_changed; + if colorize { + let adds_raw = format!("+{adds}"); + let adds_str = adds_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(); + let dels_raw = format!("-{dels}"); + let dels_str = dels_raw + .as_str() + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(); + let bullet = "•" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let file_label = "file" + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(); + let plural = if files == 1 { "" } else { "s" }; + format!("{adds_str}/{dels_str} {bullet} {files} {file_label}{plural}") + } else { + format!( + "+{adds}/-{dels} • {files} file{}", + if files == 1 { "" } else { "s" } + ) + } +} + +fn format_task_status_lines( + task: &codex_cloud_tasks_client::TaskSummary, + now: chrono::DateTime, + colorize: bool, +) -> Vec { + let mut lines = Vec::new(); + let status = task_status_label(&task.status); + let status = if colorize { + match task.status { + TaskStatus::Ready => status + .if_supports_color(Stream::Stdout, |t| t.green()) + .to_string(), + TaskStatus::Pending => status + .if_supports_color(Stream::Stdout, |t| t.magenta()) + .to_string(), + TaskStatus::Applied => status + .if_supports_color(Stream::Stdout, |t| t.blue()) + .to_string(), + TaskStatus::Error => status + .if_supports_color(Stream::Stdout, |t| t.red()) + .to_string(), + } + } else { + status.to_string() + }; + lines.push(format!("[{status}] {}", task.title)); + let mut meta_parts = Vec::new(); + if let Some(label) = task.environment_label.as_deref().filter(|s| !s.is_empty()) { + if colorize { + meta_parts.push( + label + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(label.to_string()); + } + } else if let Some(id) = task.environment_id.as_deref() { + if colorize { + meta_parts.push( + id.if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string(), + ); + } else { + meta_parts.push(id.to_string()); + } + } + let when = format_relative_time(now, task.updated_at); + meta_parts.push(if colorize { + when.as_str() + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + when + }); + let sep = if colorize { + " • " + .if_supports_color(Stream::Stdout, |t| t.dimmed()) + .to_string() + } else { + " • ".to_string() + }; + lines.push(meta_parts.join(&sep)); + lines.push(summary_line(&task.summary, colorize)); + lines +} + +async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_status").await?; + let task_id = parse_task_id(&args.task_id)?; + let summary = + codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?; + let now = Utc::now(); + let colorize = supports_color::on(SupportStream::Stdout).is_some(); + for line in format_task_status_lines(&summary, now, colorize) { + println!("{line}"); + } + if !matches!(summary.status, TaskStatus::Ready) { + std::process::exit(1); + } + Ok(()) +} + +async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_diff").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + print!("{}", selected.diff); + Ok(()) +} + +async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> { + let ctx = init_backend("codex_cloud_tasks_apply").await?; + let task_id = parse_task_id(&args.task_id)?; + let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?; + let selected = select_attempt(&attempts, args.attempt)?; + let outcome = codex_cloud_tasks_client::CloudBackend::apply_task( + &*ctx.backend, + task_id, + Some(selected.diff.clone()), + ) + .await?; + println!("{}", outcome.message); + if !matches!( + outcome.status, + codex_cloud_tasks_client::ApplyStatus::Success + ) { + std::process::exit(1); + } + Ok(()) +} + fn level_from_status(status: codex_cloud_tasks_client::ApplyStatus) -> app::ApplyResultLevel { match status { codex_cloud_tasks_client::ApplyStatus::Success => app::ApplyResultLevel::Success, @@ -322,6 +596,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an if let Some(command) = cli.command { return match command { crate::cli::Command::Exec(args) => run_exec_command(args).await, + crate::cli::Command::Status(args) => run_status_command(args).await, + crate::cli::Command::Apply(args) => run_apply_command(args).await, + crate::cli::Command::Diff(args) => run_diff_command(args).await, }; } let Cli { .. } = cli; @@ -1713,14 +1990,111 @@ fn pretty_lines_from_error(raw: &str) -> Vec { #[cfg(test)] mod tests { + use super::*; + use codex_cloud_tasks_client::DiffSummary; + use codex_cloud_tasks_client::MockClient; + use codex_cloud_tasks_client::TaskId; + use codex_cloud_tasks_client::TaskStatus; + use codex_cloud_tasks_client::TaskSummary; use codex_tui::ComposerAction; use codex_tui::ComposerInput; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; + #[test] + fn format_task_status_lines_with_diff_and_label() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_1".to_string()), + title: "Example task".to_string(), + status: TaskStatus::Ready, + updated_at: now, + environment_id: Some("env-1".to_string()), + environment_label: Some("Env".to_string()), + summary: DiffSummary { + files_changed: 3, + lines_added: 5, + lines_removed: 2, + }, + is_review: false, + attempt_total: None, + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[READY] Example task".to_string(), + "Env • 0s ago".to_string(), + "+5/-2 • 3 files".to_string(), + ] + ); + } + + #[test] + fn format_task_status_lines_without_diff_falls_back() { + let now = Utc::now(); + let task = TaskSummary { + id: TaskId("task_2".to_string()), + title: "No diff task".to_string(), + status: TaskStatus::Pending, + updated_at: now, + environment_id: Some("env-2".to_string()), + environment_label: None, + summary: DiffSummary::default(), + is_review: false, + attempt_total: Some(1), + }; + let lines = format_task_status_lines(&task, now, false); + assert_eq!( + lines, + vec![ + "[PENDING] No diff task".to_string(), + "env-2 • 0s ago".to_string(), + "no diff".to_string(), + ] + ); + } + + #[tokio::test] + async fn collect_attempt_diffs_includes_sibling_attempts() { + let backend = MockClient; + let task_id = parse_task_id("https://chatgpt.com/codex/tasks/T-1000").expect("id"); + let attempts = collect_attempt_diffs(&backend, &task_id) + .await + .expect("attempts"); + assert_eq!(attempts.len(), 2); + assert_eq!(attempts[0].placement, Some(0)); + assert_eq!(attempts[1].placement, Some(1)); + assert!(!attempts[0].diff.is_empty()); + assert!(!attempts[1].diff.is_empty()); + } + + #[test] + fn select_attempt_validates_bounds() { + let attempts = vec![AttemptDiffData { + placement: Some(0), + created_at: None, + diff: "diff --git a/file b/file\n".to_string(), + }]; + let first = select_attempt(&attempts, Some(1)).expect("attempt 1"); + assert_eq!(first.diff, "diff --git a/file b/file\n"); + assert!(select_attempt(&attempts, Some(2)).is_err()); + } + + #[test] + fn parse_task_id_from_url_and_raw() { + let raw = parse_task_id("task_i_abc123").expect("raw id"); + assert_eq!(raw.0, "task_i_abc123"); + let url = + parse_task_id("https://chatgpt.com/codex/tasks/task_i_123456?foo=bar").expect("url id"); + assert_eq!(url.0, "task_i_123456"); + assert!(parse_task_id(" ").is_err()); + } + #[test] #[ignore = "very slow"] fn composer_input_renders_typed_characters() { diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index e3a97aeb3f8..4c41ca576cf 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -20,8 +20,7 @@ use std::time::Instant; use crate::app::App; use crate::app::AttemptView; -use chrono::Local; -use chrono::Utc; +use crate::util::format_relative_time_now; use codex_cloud_tasks_client::AttemptStatus; use codex_cloud_tasks_client::TaskStatus; use codex_tui::render_markdown_text; @@ -804,7 +803,7 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) { meta.push(lbl.clone().dim()); } - let when = format_relative_time(t.updated_at).dim(); + let when = format_relative_time_now(t.updated_at).dim(); if !meta.is_empty() { meta.push(" ".into()); meta.push("•".dim()); @@ -841,27 +840,6 @@ fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> Li ListItem::new(vec![title, meta_line, sub, spacer]) } -fn format_relative_time(ts: chrono::DateTime) -> String { - let now = Utc::now(); - let mut secs = (now - ts).num_seconds(); - if secs < 0 { - secs = 0; - } - if secs < 60 { - return format!("{secs}s ago"); - } - let mins = secs / 60; - if mins < 60 { - return format!("{mins}m ago"); - } - let hours = mins / 60; - if hours < 24 { - return format!("{hours}h ago"); - } - let local = ts.with_timezone(&Local); - local.format("%b %e %H:%M").to_string() -} - fn draw_inline_spinner( frame: &mut Frame, area: Rect, diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 1c690b26c0b..79513dbcf2c 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -1,4 +1,6 @@ use base64::Engine as _; +use chrono::DateTime; +use chrono::Local; use chrono::Utc; use reqwest::header::HeaderMap; @@ -120,3 +122,27 @@ pub fn task_url(base_url: &str, task_id: &str) -> String { } format!("{normalized}/codex/tasks/{task_id}") } + +pub fn format_relative_time(reference: DateTime, ts: DateTime) -> String { + let mut secs = (reference - ts).num_seconds(); + if secs < 0 { + secs = 0; + } + if secs < 60 { + return format!("{secs}s ago"); + } + let mins = secs / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let local = ts.with_timezone(&Local); + local.format("%b %e %H:%M").to_string() +} + +pub fn format_relative_time_now(ts: DateTime) -> String { + format_relative_time(Utc::now(), ts) +} diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index fec8d7f292e..5de08432f04 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -8,6 +8,7 @@ use codex_client::RequestTelemetry; use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; use http::Method; +use http::header::ETAG; use std::sync::Arc; pub struct ModelsClient { @@ -59,12 +60,23 @@ impl ModelsClient { ) .await?; - serde_json::from_slice::(&resp.body).map_err(|e| { - ApiError::Stream(format!( - "failed to decode models response: {e}; body: {}", - String::from_utf8_lossy(&resp.body) - )) - }) + let header_etag = resp + .headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + + let ModelsResponse { models, etag } = serde_json::from_slice::(&resp.body) + .map_err(|e| { + ApiError::Stream(format!( + "failed to decode models response: {e}; body: {}", + String::from_utf8_lossy(&resp.body) + )) + })?; + + let etag = header_etag.unwrap_or(etag); + + Ok(ModelsResponse { models, etag }) } } @@ -86,20 +98,36 @@ mod tests { use std::sync::Mutex; use std::time::Duration; - #[derive(Clone, Default)] + #[derive(Clone)] struct CapturingTransport { last_request: Arc>>, body: Arc, } + impl Default for CapturingTransport { + fn default() -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(ModelsResponse { + models: Vec::new(), + etag: String::new(), + }), + } + } + } + #[async_trait] impl HttpTransport for CapturingTransport { async fn execute(&self, req: Request) -> Result { *self.last_request.lock().unwrap() = Some(req); let body = serde_json::to_vec(&*self.body).unwrap(); + let mut headers = HeaderMap::new(); + if !self.body.etag.is_empty() { + headers.insert(ETAG, self.body.etag.parse().unwrap()); + } Ok(Response { status: StatusCode::OK, - headers: HeaderMap::new(), + headers, body: body.into(), }) } @@ -138,7 +166,10 @@ mod tests { #[tokio::test] async fn appends_client_version_query() { - let response = ModelsResponse { models: Vec::new() }; + let response = ModelsResponse { + models: Vec::new(), + etag: String::new(), + }; let transport = CapturingTransport { last_request: Arc::new(Mutex::new(None)), @@ -181,15 +212,17 @@ mod tests { "display_name": "gpt-test", "description": "desc", "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium", "high"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}, {"effort": "high", "description": "high"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 99, 0], "supported_in_api": true, - "priority": 1 + "priority": 1, + "upgrade": null, })) .unwrap(), ], + etag: String::new(), }; let transport = CapturingTransport { @@ -213,4 +246,31 @@ mod tests { assert_eq!(result.models[0].supported_in_api, true); assert_eq!(result.models[0].priority, 1); } + + #[tokio::test] + async fn list_models_includes_etag() { + let response = ModelsResponse { + models: Vec::new(), + etag: "\"abc\"".to_string(), + }; + + let transport = CapturingTransport { + last_request: Arc::new(Mutex::new(None)), + body: Arc::new(response), + }; + + let client = ModelsClient::new( + transport, + provider("https://example.com/api/codex"), + DummyAuth, + ); + + let result = client + .list_models("0.1.0", HeaderMap::new()) + .await + .expect("request should succeed"); + + assert_eq!(result.models.len(), 0); + assert_eq!(result.etag, "\"abc\""); + } } diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs index 5e48c57bd85..21adfa571a2 100644 --- a/codex-rs/codex-api/src/sse/chat.rs +++ b/codex-rs/codex-api/src/sse/chat.rs @@ -10,6 +10,7 @@ use eventsource_stream::Eventsource; use futures::Stream; use futures::StreamExt; use std::collections::HashMap; +use std::collections::HashSet; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; @@ -41,12 +42,17 @@ pub async fn process_chat_sse( #[derive(Default, Debug)] struct ToolCallState { + id: Option, name: Option, arguments: String, } - let mut tool_calls: HashMap = HashMap::new(); - let mut tool_call_order: Vec = Vec::new(); + let mut tool_calls: HashMap = HashMap::new(); + let mut tool_call_order: Vec = Vec::new(); + let mut tool_call_order_seen: HashSet = HashSet::new(); + let mut tool_call_index_by_id: HashMap = HashMap::new(); + let mut next_tool_call_index = 0usize; + let mut last_tool_call_index: Option = None; let mut assistant_item: Option = None; let mut reasoning_item: Option = None; let mut completed_sent = false; @@ -149,15 +155,40 @@ pub async fn process_chat_sse( if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) { for tool_call in tool_call_values { - let id = tool_call - .get("id") - .and_then(|i| i.as_str()) - .map(str::to_string) - .unwrap_or_else(|| format!("tool-call-{}", tool_call_order.len())); - - let call_state = tool_calls.entry(id.clone()).or_default(); - if !tool_call_order.contains(&id) { - tool_call_order.push(id.clone()); + let mut index = tool_call + .get("index") + .and_then(serde_json::Value::as_u64) + .map(|i| i as usize); + + let mut call_id_for_lookup = None; + if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) { + call_id_for_lookup = Some(call_id.to_string()); + if let Some(existing) = tool_call_index_by_id.get(call_id) { + index = Some(*existing); + } + } + + if index.is_none() && call_id_for_lookup.is_none() { + index = last_tool_call_index; + } + + let index = index.unwrap_or_else(|| { + while tool_calls.contains_key(&next_tool_call_index) { + next_tool_call_index += 1; + } + let idx = next_tool_call_index; + next_tool_call_index += 1; + idx + }); + + let call_state = tool_calls.entry(index).or_default(); + if tool_call_order_seen.insert(index) { + tool_call_order.push(index); + } + + if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) { + call_state.id.get_or_insert_with(|| id.to_string()); + tool_call_index_by_id.entry(id.to_string()).or_insert(index); } if let Some(func) = tool_call.get("function") { @@ -171,6 +202,8 @@ pub async fn process_chat_sse( call_state.arguments.push_str(arguments); } } + + last_tool_call_index = Some(index); } } } @@ -224,13 +257,25 @@ pub async fn process_chat_sse( .await; } - for call_id in tool_call_order.drain(..) { - let state = tool_calls.remove(&call_id).unwrap_or_default(); + for index in tool_call_order.drain(..) { + let Some(state) = tool_calls.remove(&index) else { + continue; + }; + tool_call_order_seen.remove(&index); + let ToolCallState { + id, + name, + arguments, + } = state; + let Some(name) = name else { + debug!("Skipping tool call at index {index} because name is missing"); + continue; + }; let item = ResponseItem::FunctionCall { id: None, - name: state.name.unwrap_or_default(), - arguments: state.arguments, - call_id: call_id.clone(), + name, + arguments, + call_id: id.unwrap_or_else(|| format!("tool-call-{index}")), }; let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } @@ -335,6 +380,59 @@ mod tests { out } + #[tokio::test] + async fn concatenates_tool_call_arguments_across_deltas() { + let delta_name = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "id": "call_a", + "index": 0, + "function": { "name": "do_a" } + }] + } + }] + }); + + let delta_args_1 = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "{ \"foo\":" } + }] + } + }] + }); + + let delta_args_2 = json!({ + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "1}" } + }] + } + }] + }); + + let finish = json!({ + "choices": [{ + "finish_reason": "tool_calls" + }] + }); + + let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }), + ResponseEvent::Completed { .. } + ] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}" + ); + } + #[tokio::test] async fn emits_multiple_tool_calls() { let delta_a = json!({ @@ -367,50 +465,74 @@ mod tests { let body = build_body(&[delta_a, delta_b, finish]); let events = collect_events(&body).await; - assert_eq!(events.len(), 3); - assert_matches!( - &events[0], - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }) - if call_id == "call_a" && name == "do_a" && arguments == "{\"foo\":1}" - ); - assert_matches!( - &events[1], - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }) - if call_id == "call_b" && name == "do_b" && arguments == "{\"bar\":2}" + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), + ResponseEvent::Completed { .. } + ] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}" ); - assert_matches!(events[2], ResponseEvent::Completed { .. }); } #[tokio::test] - async fn concatenates_tool_call_arguments_across_deltas() { - let delta_name = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "function": { "name": "do_a" } - }] + async fn emits_tool_calls_for_multiple_choices() { + let payload = json!({ + "choices": [ + { + "delta": { + "tool_calls": [{ + "id": "call_a", + "index": 0, + "function": { "name": "do_a", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" + }, + { + "delta": { + "tool_calls": [{ + "id": "call_b", + "index": 0, + "function": { "name": "do_b", "arguments": "{}" } + }] + }, + "finish_reason": "tool_calls" } - }] + ] }); - let delta_args_1 = json!({ + let body = build_body(&[payload]); + let events = collect_events(&body).await; + assert_matches!( + &events[..], + [ + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), + ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), + ResponseEvent::Completed { .. } + ] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}" + ); + } + + #[tokio::test] + async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() { + let delta_with_id = json!({ "choices": [{ "delta": { "tool_calls": [{ + "index": 0, "id": "call_a", - "function": { "arguments": "{ \"foo\":" } + "function": { "name": "do_a", "arguments": "{ \"foo\":" } }] } }] }); - let delta_args_2 = json!({ + let delta_without_id = json!({ "choices": [{ "delta": { "tool_calls": [{ - "id": "call_a", + "index": 0, "function": { "arguments": "1}" } }] } @@ -423,7 +545,7 @@ mod tests { }] }); - let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); + let body = build_body(&[delta_with_id, delta_without_id, finish]); let events = collect_events(&body).await; assert_matches!( &events[..], diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3b4077f5342..20eb64d5cd3 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -10,6 +10,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use http::HeaderMap; use http::Method; use wiremock::Mock; @@ -57,16 +58,28 @@ async fn models_client_hits_models_endpoint() { description: Some("desc".to_string()), default_reasoning_level: ReasoningEffort::Medium, supported_reasoning_levels: vec![ - ReasoningEffort::Low, - ReasoningEffort::Medium, - ReasoningEffort::High, + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: ReasoningEffort::Low.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: ReasoningEffort::High.to_string(), + }, ], shell_type: ConfigShellToolType::ShellCommand, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, + base_instructions: None, }], + etag: String::new(), }; Mock::given(method("GET")) diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs new file mode 100644 index 00000000000..8a25846385a --- /dev/null +++ b/codex-rs/codex-client/src/default_client.rs @@ -0,0 +1,143 @@ +use http::Error as HttpError; +use reqwest::IntoUrl; +use reqwest::Method; +use reqwest::Response; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Display; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct CodexHttpClient { + inner: reqwest::Client, +} + +impl CodexHttpClient { + pub fn new(inner: reqwest::Client) -> Self { + Self { inner } + } + + pub fn get(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::GET, url) + } + + pub fn post(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + let url_str = url.as_str().to_string(); + CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + } +} + +#[must_use = "requests are not sent unless `send` is awaited"] +#[derive(Debug)] +pub struct CodexRequestBuilder { + builder: reqwest::RequestBuilder, + method: Method, + url: String, +} + +impl CodexRequestBuilder { + fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { + Self { + builder, + method, + url, + } + } + + fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { + Self { + builder: f(self.builder), + method: self.method, + url: self.url, + } + } + + pub fn headers(self, headers: HeaderMap) -> Self { + self.map(|builder| builder.headers(headers)) + } + + pub fn header(self, key: K, value: V) -> Self + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + self.map(|builder| builder.header(key, value)) + } + + pub fn bearer_auth(self, token: T) -> Self + where + T: Display, + { + self.map(|builder| builder.bearer_auth(token)) + } + + pub fn timeout(self, timeout: Duration) -> Self { + self.map(|builder| builder.timeout(timeout)) + } + + pub fn json(self, value: &T) -> Self + where + T: ?Sized + Serialize, + { + self.map(|builder| builder.json(value)) + } + + pub async fn send(self) -> Result { + match self.builder.send().await { + Ok(response) => { + let request_ids = Self::extract_request_ids(&response); + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + request_ids = ?request_ids, + version = ?response.version(), + "Request completed" + ); + + Ok(response) + } + Err(error) => { + let status = error.status(); + tracing::debug!( + method = %self.method, + url = %self.url, + status = status.map(|s| s.as_u16()), + error = %error, + "Request failed" + ); + Err(error) + } + } + } + + fn extract_request_ids(response: &Response) -> HashMap { + ["cf-ray", "x-request-id", "x-oai-request-id"] + .iter() + .filter_map(|&name| { + let header_name = HeaderName::from_static(name); + let value = response.headers().get(header_name)?; + let value = value.to_str().ok()?.to_owned(); + Some((name.to_owned(), value)) + }) + .collect() + } +} diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 3ac00a21a8b..66d1083c07d 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,3 +1,4 @@ +mod default_client; mod error; mod request; mod retry; @@ -5,6 +6,8 @@ mod sse; mod telemetry; mod transport; +pub use crate::default_client::CodexHttpClient; +pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; pub use crate::request::Request; diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs index 5edc9a7b779..986ba3a6792 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/codex-client/src/transport.rs @@ -1,3 +1,5 @@ +use crate::default_client::CodexHttpClient; +use crate::default_client::CodexRequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::Response; @@ -28,15 +30,17 @@ pub trait HttpTransport: Send + Sync { #[derive(Clone, Debug)] pub struct ReqwestTransport { - client: reqwest::Client, + client: CodexHttpClient, } impl ReqwestTransport { pub fn new(client: reqwest::Client) -> Self { - Self { client } + Self { + client: CodexHttpClient::new(client), + } } - fn build(&self, req: Request) -> Result { + fn build(&self, req: Request) -> Result { let mut builder = self .client .request( diff --git a/codex-rs/common/src/config_summary.rs b/codex-rs/common/src/config_summary.rs index 32b837f1f52..5a5901880f2 100644 --- a/codex-rs/common/src/config_summary.rs +++ b/codex-rs/common/src/config_summary.rs @@ -4,10 +4,10 @@ use codex_core::config::Config; use crate::sandbox_summary::summarize_sandbox_policy; /// Build a list of key/value pairs summarizing the effective configuration. -pub fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { +pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'static str, String)> { let mut entries = vec![ ("workdir", config.cwd.display().to_string()), - ("model", config.model.clone()), + ("model", model.to_string()), ("provider", config.model_provider_id.clone()), ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8a329d06728..4c231e4dda5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "codex-core" -version.workspace = true edition.workspace = true license.workspace = true +name = "codex-core" +version.workspace = true [lib] doctest = false @@ -14,16 +14,16 @@ workspace = true [dependencies] anyhow = { workspace = true } -askama = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } -chrono = { workspace = true, features = ["serde"] } chardetng = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } -codex-api = { workspace = true } +codex-client = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } @@ -31,14 +31,15 @@ codex-keyring-store = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } dirs = { workspace = true } dunce = { workspace = true } -env-flags = { workspace = true } encoding_rs = { workspace = true } +env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } @@ -46,8 +47,10 @@ indexmap = { workspace = true } keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } +once_cell = { workspace = true } os_info = { workspace = true } rand = { workspace = true } +regex = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } serde = { workspace = true, features = ["derive"] } @@ -58,9 +61,6 @@ sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } strum_macros = { workspace = true } -url = { workspace = true } -once_cell = { workspace = true } -regex = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" test-log = { workspace = true } @@ -84,18 +84,20 @@ toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } +url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } [features] deterministic_process_ids = [] +test-support = [] [target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native-async-persistent"] } landlock = { workspace = true } seccompiler = { workspace = true } -keyring = { workspace = true, features = ["linux-native-async-persistent"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" diff --git a/codex-rs/core/gpt-5.1-codex-max_prompt.md b/codex-rs/core/gpt-5.1-codex-max_prompt.md index 292e5d7d0f1..a8227c893f0 100644 --- a/codex-rs/core/gpt-5.1-codex-max_prompt.md +++ b/codex-rs/core/gpt-5.1-codex-max_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/gpt_5_1_prompt.md b/codex-rs/core/gpt_5_1_prompt.md index 97a3875fe57..3201ffeb684 100644 --- a/codex-rs/core/gpt_5_1_prompt.md +++ b/codex-rs/core/gpt_5_1_prompt.md @@ -182,7 +182,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -193,8 +193,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Validating your work diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 57d06761ba2..e2f9017874a 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 72359ca4cae..20943982d4d 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -23,7 +23,6 @@ pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::config::Config; -use crate::default_client::CodexHttpClient; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; use crate::token_data::KnownPlan as InternalKnownPlan; @@ -31,8 +30,11 @@ use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use codex_client::CodexHttpClient; use codex_protocol::account::PlanType as AccountPlanType; +use once_cell::sync::Lazy; use serde_json::Value; +use tempfile::TempDir; use thiserror::Error; #[derive(Debug, Clone)] @@ -62,6 +64,8 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +static TEST_AUTH_TEMP_DIRS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); + #[derive(Debug, Error)] pub enum RefreshTokenError { #[error("{0}")] @@ -1088,11 +1092,19 @@ impl AuthManager { } } + #[cfg(any(test, feature = "test-support"))] + #[expect(clippy::expect_used)] /// Create an AuthManager with a specific CodexAuth, for testing only. pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { let cached = CachedAuth { auth: Some(auth) }; + let temp_dir = tempfile::tempdir().expect("temp codex home"); + let codex_home = temp_dir.path().to_path_buf(); + TEST_AUTH_TEMP_DIRS + .lock() + .expect("lock test codex homes") + .push(temp_dir); Arc::new(Self { - codex_home: PathBuf::new(), + codex_home, inner: RwLock::new(cached), enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, @@ -1104,6 +1116,10 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + pub fn codex_home(&self) -> &Path { + &self.codex_home + } + /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4c3cf737b23..72c23a3ea40 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -48,7 +48,6 @@ use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; -use crate::openai_model_info::get_model_info; use crate::openai_models::model_family::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; @@ -95,19 +94,11 @@ impl ModelClient { pub fn get_model_context_window(&self) -> Option { let model_family = self.get_model_family(); let effective_context_window_percent = model_family.effective_context_window_percent; - self.config - .model_context_window - .or_else(|| get_model_info(&model_family).map(|info| info.context_window)) + model_family + .context_window .map(|w| w.saturating_mul(effective_context_window_percent) / 100) } - pub fn get_auto_compact_token_limit(&self) -> Option { - let model_family = self.get_model_family(); - self.config.model_auto_compact_token_limit.or_else(|| { - get_model_info(&model_family).and_then(|info| info.auto_compact_token_limit) - }) - } - pub fn config(&self) -> Arc { Arc::clone(&self.config) } @@ -175,7 +166,7 @@ impl ModelClient { let stream_result = client .stream_prompt( - &self.config.model, + &self.get_model(), &api_prompt, Some(conversation_id.clone()), Some(session_source.clone()), @@ -269,7 +260,7 @@ impl ModelClient { }; let stream_result = client - .stream_prompt(&self.config.model, &api_prompt, options) + .stream_prompt(&self.get_model(), &api_prompt, options) .await; match stream_result { @@ -301,7 +292,7 @@ impl ModelClient { /// Returns the currently configured model slug. pub fn get_model(&self) -> String { - self.config.model.clone() + self.get_model_family().get_model_slug().to_string() } /// Returns the currently configured model family. @@ -346,7 +337,7 @@ impl ModelClient { .get_full_instructions(&self.get_model_family()) .into_owned(); let payload = ApiCompactionInput { - model: &self.config.model, + model: &self.get_model(), input: &prompt.input, instructions: &instructions, }; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d35f95e4238..e23e03298d4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -14,6 +14,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::exec_policy::load_exec_policy_for_features; use crate::features::Feature; use crate::features::Features; +use crate::openai_models::model_family::ModelFamily; use crate::openai_models::models_manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -79,7 +80,6 @@ use crate::exec::StreamOutput; use crate::exec_policy::ExecPolicyUpdateError; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; -use crate::openai_model_info::get_model_info; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentReasoningSectionBreakEvent; @@ -95,9 +95,11 @@ use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::SkillErrorInfo; +use crate::protocol::SkillInfo; +use crate::protocol::SkillLoadOutcomeInfo; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TokenCountEvent; @@ -109,6 +111,11 @@ use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; +use crate::shell_snapshot::ShellSnapshot; +use crate::skills::SkillInjections; +use crate::skills::SkillLoadOutcome; +use crate::skills::build_skill_injections; +use crate::skills::load_skills; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -173,7 +180,31 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let user_instructions = get_user_instructions(&config).await; + let loaded_skills = if config.features.enabled(Feature::Skills) { + Some(load_skills(&config)) + } else { + None + }; + + if let Some(outcome) = &loaded_skills { + for err in &outcome.errors { + error!( + "failed to load skill {}: {}", + err.path.display(), + err.message + ); + } + } + + let skills_outcome = loaded_skills.clone(); + + let user_instructions = get_user_instructions( + &config, + skills_outcome + .as_ref() + .map(|outcome| outcome.skills.as_slice()), + ) + .await; let exec_policy = load_exec_policy_for_features(&config.features, &config.codex_home) .await @@ -181,10 +212,15 @@ impl Codex { let exec_policy = Arc::new(RwLock::new(exec_policy)); let config = Arc::new(config); - + if config.features.enabled(Feature::RemoteModels) + && let Err(err) = models_manager.refresh_available_models(&config).await + { + error!("failed to refresh available models: {err:?}"); + } + let model = models_manager.get_model(&config.model, &config).await; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model: model.clone(), model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -201,6 +237,7 @@ impl Codex { // Generate a unique ID for the lifetime of this Codex session. let session_source_clone = session_configuration.session_source.clone(); + let session = Session::new( session_configuration, config.clone(), @@ -209,6 +246,7 @@ impl Codex { tx_event.clone(), conversation_history, session_source_clone, + skills_outcome.clone(), ) .await .map_err(|e| { @@ -398,35 +436,36 @@ pub(crate) struct SessionSettingsUpdate { } impl Session { + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. + fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { + // todo(aibrahim): store this state somewhere else so we don't need to mut config + let config = session_configuration.original_config_do_not_use.clone(); + let mut per_turn_config = (*config).clone(); + per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + per_turn_config.features = config.features.clone(); + per_turn_config + } + + #[allow(clippy::too_many_arguments)] fn make_turn_context( auth_manager: Option>, - models_manager: Arc, otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, + per_turn_config: Config, + model_family: ModelFamily, conversation_id: ConversationId, sub_id: String, ) -> TurnContext { - let config = session_configuration.original_config_do_not_use.clone(); - let features = &config.features; - let mut per_turn_config = (*config).clone(); - per_turn_config.model = session_configuration.model.clone(); - per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; - per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.features = features.clone(); - let model_family = - models_manager.construct_model_family(&per_turn_config.model, &per_turn_config); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } - let otel_event_manager = otel_event_manager.clone().with_model( session_configuration.model.as_str(), - session_configuration.model.as_str(), + model_family.get_model_slug(), ); + let per_turn_config = Arc::new(per_turn_config); let client = ModelClient::new( - Arc::new(per_turn_config.clone()), + per_turn_config.clone(), auth_manager, model_family.clone(), otel_event_manager, @@ -439,7 +478,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features, + features: &per_turn_config.features, }); TurnContext { @@ -452,19 +491,20 @@ impl Session { user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy, sandbox_policy: session_configuration.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), + shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, final_output_json_schema: None, - codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), exec_policy: session_configuration.exec_policy.clone(), truncation_policy: TruncationPolicy::new( - &per_turn_config, + per_turn_config.as_ref(), model_family.truncation_policy, ), } } + #[allow(clippy::too_many_arguments)] async fn new( session_configuration: SessionConfiguration, config: Arc, @@ -473,6 +513,7 @@ impl Session { tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, + skills: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -510,7 +551,6 @@ impl Session { // - load history metadata let rollout_fut = RolloutRecorder::new(&config, rollout_params); - let default_shell = shell::default_user_shell(); let history_meta_fut = crate::message_history::history_metadata(&config); let auth_statuses_fut = compute_auth_statuses( config.mcp_servers.iter(), @@ -531,7 +571,7 @@ impl Session { for (alias, feature) in config.features.legacy_feature_usages() { let canonical = feature.key(); - let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); + let summary = format!("`{alias}` is deprecated. Use `[features].{canonical}` instead."); let details = if alias == canonical { None } else { @@ -545,12 +585,11 @@ impl Session { }); } - let model_family = models_manager.construct_model_family(&config.model, &config); // todo(aibrahim): why are we passing model here while it can change? let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), - model_family.slug.as_str(), + session_configuration.model.as_str(), + session_configuration.model.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), @@ -570,7 +609,14 @@ impl Session { config.active_profile.clone(), ); + 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, &default_shell) + .await + .map(Arc::new); + } let state = SessionState::new(session_configuration.clone()); let services = SessionServices { @@ -579,12 +625,13 @@ impl Session { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), - user_shell: default_shell, + user_shell: Arc::new(default_shell), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), + skills: skills.clone(), }; let sess = Arc::new(Session { @@ -600,6 +647,7 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); + let skill_load_outcome = skill_load_outcome_for_client(skills.as_ref()); let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -614,6 +662,7 @@ impl Session { history_log_id, history_entry_count, initial_messages, + skill_load_outcome, rollout_path, }), }) @@ -768,12 +817,19 @@ impl Session { session_configuration }; + let per_turn_config = Self::build_per_turn_config(&session_configuration); + let model_family = self + .services + .models_manager + .construct_model_family(session_configuration.model.as_str(), &per_turn_config) + .await; let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), - Arc::clone(&self.services.models_manager), &self.services.otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, self.conversation_id, sub_id, ); @@ -790,14 +846,16 @@ impl Session { ) -> Option { let prev = previous?; - let prev_context = EnvironmentContext::from(prev.as_ref()); - let next_context = EnvironmentContext::from(next); + let shell = self.user_shell(); + let prev_context = EnvironmentContext::from_turn_context(prev.as_ref(), shell.as_ref()); + let next_context = EnvironmentContext::from_turn_context(next, shell.as_ref()); if prev_context.equals_except_shell(&next_context) { return None; } Some(ResponseItem::from(EnvironmentContext::diff( prev.as_ref(), next, + shell.as_ref(), ))) } @@ -857,34 +915,6 @@ impl Session { .await; } - pub(crate) async fn assess_sandbox_command( - &self, - turn_context: &TurnContext, - call_id: &str, - command: &[String], - failure_message: Option<&str>, - ) -> Option { - let config = turn_context.client.config(); - let provider = turn_context.client.provider().clone(); - let auth_manager = Arc::clone(&self.services.auth_manager); - let otel = self.services.otel_event_manager.clone(); - crate::sandboxing::assessment::assess_command( - config, - provider, - auth_manager, - &otel, - self.conversation_id, - self.services.models_manager.clone(), - turn_context.client.get_session_source(), - call_id, - command, - &turn_context.sandbox_policy, - &turn_context.cwd, - failure_message, - ) - .await - } - /// Adds an execpolicy amendment to both the in-memory and on-disk policies so future /// commands can use the newly approved prefix. pub(crate) async fn persist_execpolicy_amendment( @@ -932,7 +962,6 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); @@ -960,7 +989,6 @@ impl Session { command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }); @@ -1147,6 +1175,7 @@ impl Session { pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { let mut items = Vec::::with_capacity(3); + let shell = self.user_shell(); if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); } @@ -1163,7 +1192,7 @@ impl Session { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - self.user_shell().clone(), + shell.as_ref().clone(), ))); items } @@ -1438,8 +1467,8 @@ impl Session { &self.services.notifier } - pub(crate) fn user_shell(&self) -> &shell::Shell { - &self.services.user_shell + pub(crate) fn user_shell(&self) -> Arc { + Arc::clone(&self.services.user_shell) } fn show_raw_agent_reasoning(&self) -> bool { @@ -1907,7 +1936,8 @@ async fn spawn_review_thread( let review_model_family = sess .services .models_manager - .construct_model_family(&model, &config); + .construct_model_family(&model, &config) + .await; // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); review_features @@ -1926,19 +1956,15 @@ async fn spawn_review_thread( // Build per‑turn client with the requested model/family. let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; per_turn_config.features = review_features.clone(); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } let otel_event_manager = parent_turn_context .client .get_otel_event_manager() .with_model( - per_turn_config.model.as_str(), + config.review_model.as_str(), review_model_family.slug.as_str(), ); @@ -1990,6 +2016,30 @@ async fn spawn_review_thread( .await; } +fn skill_load_outcome_for_client( + outcome: Option<&SkillLoadOutcome>, +) -> Option { + outcome.map(|outcome| SkillLoadOutcomeInfo { + skills: outcome + .skills + .iter() + .map(|skill| SkillInfo { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + }) + .collect(), + errors: outcome + .errors + .iter() + .map(|err| SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect(), + }) +} + /// Takes a user message as input and runs a loop where, at each turn, the model /// replies with either: /// @@ -2018,11 +2068,26 @@ pub(crate) async fn run_task( }); sess.send_event(&turn_context, event).await; + let SkillInjections { + items: skill_items, + warnings: skill_warnings, + } = build_skill_injections(&input, sess.services.skills.as_ref()).await; + + for message in skill_warnings { + sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + } + let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); let response_item: ResponseItem = initial_input_for_turn.clone().into(); sess.record_response_item_and_emit_turn_item(turn_context.as_ref(), response_item) .await; + if !skill_items.is_empty() { + sess.record_conversation_items(&turn_context, &skill_items) + .await; + } + sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) .await; let mut last_agent_message: Option = None; @@ -2072,7 +2137,8 @@ pub(crate) async fn run_task( } = turn_output; let limit = turn_context .client - .get_auto_compact_token_limit() + .get_model_family() + .auto_compact_token_limit() .unwrap_or(i64::MAX); let total_usage_tokens = sess.get_total_token_usage().await; let token_limit_reached = total_usage_tokens >= limit; @@ -2156,21 +2222,11 @@ async fn run_turn( .get_model_family() .supports_parallel_tool_calls; - // TODO(jif) revert once testing phase is done. - let parallel_tool_calls = model_supports_parallel && sess.enabled(Feature::ParallelToolCalls); - let mut base_instructions = turn_context.base_instructions.clone(); - if parallel_tool_calls { - static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); - let family = turn_context.client.get_model_family(); - let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); - new_instructions.push_str(INSTRUCTIONS); - base_instructions = Some(new_instructions); - } let prompt = Prompt { input, tools: router.specs(), - parallel_tool_calls, - base_instructions_override: base_instructions, + parallel_tool_calls: model_supports_parallel && sess.enabled(Feature::ParallelToolCalls), + base_instructions_override: turn_context.base_instructions.clone(), output_schema: turn_context.final_output_json_schema.clone(), }; @@ -2568,9 +2624,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2639,9 +2696,10 @@ mod tests { ) .expect("load default test config"); let config = Arc::new(config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2812,15 +2870,12 @@ mod tests { fn otel_event_manager( conversation_id: ConversationId, config: &Config, - models_manager: &ModelsManager, + model_family: &ModelFamily, ) -> OtelEventManager { OtelEventManager::new( conversation_id, - config.model.as_str(), - models_manager - .construct_model_family(&config.model, config) - .slug - .as_str(), + ModelsManager::get_model_offline(config.model.as_deref()).as_str(), + model_family.slug.as_str(), None, Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), @@ -2843,12 +2898,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let otel_event_manager = - otel_event_manager(conversation_id, config.as_ref(), &models_manager); - + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2862,6 +2915,13 @@ mod tests { exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &model_family); let state = SessionState::new(session_configuration.clone()); @@ -2871,20 +2931,22 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: auth_manager.clone(), otel_event_manager: otel_event_manager.clone(), - models_manager: models_manager.clone(), + models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), + skills: None, }; let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), - models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, conversation_id, "turn_id".to_string(), ); @@ -2922,12 +2984,10 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let otel_event_manager = - otel_event_manager(conversation_id, config.as_ref(), &models_manager); - + let model = ModelsManager::get_model_offline(config.model.as_deref()); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), - model: config.model.clone(), + model, model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), @@ -2941,6 +3001,13 @@ mod tests { exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; + let per_turn_config = Session::build_per_turn_config(&session_configuration); + let model_family = ModelsManager::construct_model_family_offline( + session_configuration.model.as_str(), + &per_turn_config, + ); + let otel_event_manager = + otel_event_manager(conversation_id, config.as_ref(), &model_family); let state = SessionState::new(session_configuration.clone()); @@ -2950,20 +3017,22 @@ mod tests { unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::new(None), rollout: Mutex::new(None), - user_shell: default_user_shell(), + user_shell: Arc::new(default_user_shell()), show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), - models_manager: models_manager.clone(), + models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), + skills: None, }; let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), - models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, + per_turn_config, + model_family, conversation_id, "turn_id".to_string(), )); @@ -3304,6 +3373,7 @@ mod tests { use crate::exec::ExecParams; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; use std::collections::HashMap; @@ -3314,6 +3384,7 @@ mod tests { let mut turn_context = Arc::new(turn_context_raw); let timeout_ms = 1000; + let sandbox_permissions = SandboxPermissions::RequireEscalated; let params = ExecParams { command: if cfg!(windows) { vec![ @@ -3331,13 +3402,13 @@ mod tests { cwd: turn_context.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), - with_escalated_permissions: Some(true), + sandbox_permissions, justification: Some("test".to_string()), arg0: None, }; let params2 = ExecParams { - with_escalated_permissions: Some(false), + sandbox_permissions: SandboxPermissions::UseDefault, command: params.command.clone(), cwd: params.cwd.clone(), expiration: timeout_ms.into(), @@ -3364,7 +3435,7 @@ mod tests { "command": params.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params.expiration.timeout_ms(), - "with_escalated_permissions": params.with_escalated_permissions, + "sandbox_permissions": params.sandbox_permissions, "justification": params.justification.clone(), }) .to_string(), @@ -3401,7 +3472,7 @@ mod tests { "command": params2.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params2.expiration.timeout_ms(), - "with_escalated_permissions": params2.with_escalated_permissions, + "sandbox_permissions": params2.sandbox_permissions, "justification": params2.justification.clone(), }) .to_string(), @@ -3434,6 +3505,7 @@ mod tests { #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::protocol::AskForApproval; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; let (session, mut turn_context_raw) = make_session_and_context(); @@ -3453,7 +3525,7 @@ mod tests { payload: ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo hi", - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, "justification": "need unsandboxed execution", }) .to_string(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 670225ead06..75b29eddeed 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -280,7 +280,6 @@ async fn handle_exec_approval( event.command, event.cwd, event.reason, - event.risk, event.proposed_execpolicy_amendment, ); let decision = await_approval_with_cancel( diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 68e2d206f0d..37c2aba6efd 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -555,6 +555,14 @@ impl ConfigEditsBuilder { self } + pub fn with_edits(mut self, edits: I) -> Self + where + I: IntoIterator, + { + self.edits.extend(edits); + self + } + /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) @@ -603,6 +611,24 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } + #[test] + fn builder_with_edits_applies_custom_paths() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits(vec![ConfigEdit::SetPath { + segments: vec!["enabled".to_string()], + value: value(true), + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "enabled = true\n"); + } + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a1cc46cf230..e0e6985a39d 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -26,8 +26,6 @@ use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; -use crate::openai_model_info::get_model_info; -use crate::openai_models::model_family::find_family_for_model; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; @@ -42,6 +40,7 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_utils_absolute_path::AbsolutePathBufGuard; use dirs::home_dir; use dunce::canonicalize; use serde::Deserialize; @@ -60,7 +59,6 @@ pub mod edit; pub mod profile; pub mod types; -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -74,7 +72,7 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; #[derive(Debug, Clone, PartialEq)] pub struct Config { /// Optional override of model selection. - pub model: String, + pub model: Option, /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, @@ -247,9 +245,6 @@ pub struct Config { pub tools_web_search_request: bool, - /// When `true`, run a model-based assessment for commands denied by the sandbox. - pub experimental_sandbox_command_assessment: bool, - /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -301,9 +296,9 @@ impl Config { ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { + let cfg = deserialize_config_toml_with_base(root_value, &codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) @@ -321,9 +316,9 @@ pub async fn load_config_as_toml_with_cli_overrides( ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { + let cfg = deserialize_config_toml_with_base(root_value, codex_home).map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) + e })?; Ok(cfg) @@ -359,6 +354,18 @@ fn apply_overlays( base } +fn deserialize_config_toml_with_base( + root_value: TomlValue, + config_base_dir: &Path, +) -> std::io::Result { + // This guard ensures that any relative paths that is deserialized into an + // [AbsolutePathBuf] is resolved against `config_base_dir`. + let _guard = AbsolutePathBufGuard::new(config_base_dir); + root_value + .try_into() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { @@ -722,7 +729,6 @@ pub struct ConfigToml { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". pub oss_provider: Option, } @@ -908,7 +914,6 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -967,7 +972,6 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, additional_writable_roots, } = overrides; @@ -992,7 +996,6 @@ impl Config { let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); @@ -1091,8 +1094,6 @@ impl Config { let tools_web_search_request = features.enabled(Feature::WebSearchRequest); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); - let experimental_sandbox_command_assessment = - features.enabled(Feature::SandboxCommandAssessment); let forced_chatgpt_workspace_id = cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { @@ -1106,22 +1107,7 @@ impl Config { let forced_login_method = cfg.forced_login_method; - let model = model - .or(config_profile.model) - .or(cfg.model) - .unwrap_or_else(default_model); - - let model_family = find_family_for_model(&model); - - let openai_model_info = get_model_info(&model_family); - let model_context_window = cfg - .model_context_window - .or_else(|| openai_model_info.as_ref().map(|info| info.context_window)); - let model_auto_compact_token_limit = cfg.model_auto_compact_token_limit.or_else(|| { - openai_model_info - .as_ref() - .and_then(|info| info.auto_compact_token_limit) - }); + let model = model.or(config_profile.model).or(cfg.model); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -1168,8 +1154,8 @@ impl Config { let config = Self { model, review_model, - model_context_window, - model_auto_compact_token_limit, + model_context_window: cfg.model_context_window, + model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, model_provider_id, model_provider, cwd: resolved_cwd, @@ -1234,7 +1220,6 @@ impl Config { forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, - experimental_sandbox_command_assessment, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, features, @@ -1323,10 +1308,6 @@ impl Config { } } -fn default_model() -> String { - OPENAI_DEFAULT_MODEL.to_string() -} - fn default_review_model() -> String { OPENAI_DEFAULT_REVIEW_MODEL.to_string() } @@ -1865,10 +1846,11 @@ trust_level = "trusted" }; let root_value = load_resolved_config(codex_home.path(), Vec::new(), overrides).await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { - tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let cfg = + deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; assert_eq!( cfg.mcp_oauth_credentials_store, Some(OAuthCredentialsStoreMode::Keyring), @@ -1985,10 +1967,11 @@ trust_level = "trusted" ) .await?; - let cfg: ConfigToml = root_value.try_into().map_err(|e| { - tracing::error!("Failed to deserialize overridden config: {e}"); - std::io::Error::new(std::io::ErrorKind::InvalidData, e) - })?; + let cfg = + deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + e + })?; assert_eq!(cfg.model.as_deref(), Some("managed_config")); Ok(()) @@ -2948,10 +2931,10 @@ model_verbosity = "high" )?; assert_eq!( Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(200_000), - model_auto_compact_token_limit: Some(180_000), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, @@ -2988,7 +2971,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3023,10 +3005,10 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt3_profile_config = Config { - model: "gpt-3.5-turbo".to_string(), + model: Some("gpt-3.5-turbo".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(16_385), - model_auto_compact_token_limit: Some(14_746), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, @@ -3063,7 +3045,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3113,10 +3094,10 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_zdr_profile_config = Config { - model: "o3".to_string(), + model: Some("o3".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(200_000), - model_auto_compact_token_limit: Some(180_000), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, @@ -3153,7 +3134,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3189,10 +3169,10 @@ model_verbosity = "high" fixture.codex_home(), )?; let expected_gpt5_profile_config = Config { - model: "gpt-5.1".to_string(), + model: Some("gpt-5.1".to_string()), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_context_window: Some(272_000), - model_auto_compact_token_limit: Some(244_800), + model_context_window: None, + model_auto_compact_token_limit: None, model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, @@ -3229,7 +3209,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 5629465c404..978e1fcb639 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,7 +27,6 @@ pub struct ConfigProfile { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, pub tools_web_search: Option, pub tools_view_image: Option, /// Optional feature toggles scoped to this profile. diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 5e1b78aa7be..6648e288a57 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -3,13 +3,14 @@ // Note this file should generally be restricted to simple struct/enum // definitions that do not contain business logic. -use serde::Deserializer; +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use wildmatch::WildMatchPattern; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; @@ -285,9 +286,9 @@ pub enum OtelHttpProtocol { #[derive(Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub struct OtelTlsConfig { - pub ca_certificate: Option, - pub client_certificate: Option, - pub client_private_key: Option, + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, } /// Which OTEL exporter to use. diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index e527507c1c4..f340e1a8333 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,5 +1,7 @@ use crate::AuthManager; use crate::CodexAuth; +#[cfg(any(test, feature = "test-support"))] +use crate::ModelProviderInfo; use crate::codex::Codex; use crate::codex::CodexSpawnOk; use crate::codex::INITIAL_SUBMIT_ID; @@ -51,13 +53,17 @@ impl ConversationManager { } } + #[cfg(any(test, feature = "test-support"))] /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. - pub fn with_auth(auth: CodexAuth) -> Self { - Self::new( - crate::AuthManager::from_auth_for_testing(auth), - SessionSource::Exec, - ) + pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self { + let auth_manager = crate::AuthManager::from_auth_for_testing(auth); + Self { + conversations: Arc::new(RwLock::new(HashMap::new())), + auth_manager: auth_manager.clone(), + session_source: SessionSource::Exec, + models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)), + } } pub fn session_source(&self) -> SessionSource { @@ -212,8 +218,8 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } - pub async fn list_models(&self) -> Vec { - self.models_manager.available_models.read().await.clone() + pub async fn list_models(&self, config: &Config) -> Vec { + self.models_manager.list_models(config).await } pub fn get_models_manager(&self) -> Arc { diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 29986c401d5..7ae2f8c35ac 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,17 +1,12 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; -use http::Error as HttpError; -use reqwest::IntoUrl; -use reqwest::Method; -use reqwest::Response; -use reqwest::header::HeaderName; use reqwest::header::HeaderValue; -use serde::Serialize; -use std::collections::HashMap; -use std::fmt::Display; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::OnceLock; +use codex_client::CodexHttpClient; +pub use codex_client::CodexRequestBuilder; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -31,129 +26,6 @@ pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; -#[derive(Clone, Debug)] -pub struct CodexHttpClient { - inner: reqwest::Client, -} - -impl CodexHttpClient { - fn new(inner: reqwest::Client) -> Self { - Self { inner } - } - - pub fn get(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::GET, url) - } - - pub fn post(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::POST, url) - } - - pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - let url_str = url.as_str().to_string(); - CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) - } -} - -#[must_use = "requests are not sent unless `send` is awaited"] -#[derive(Debug)] -pub struct CodexRequestBuilder { - builder: reqwest::RequestBuilder, - method: Method, - url: String, -} - -impl CodexRequestBuilder { - fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { - Self { - builder, - method, - url, - } - } - - fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { - Self { - builder: f(self.builder), - method: self.method, - url: self.url, - } - } - - pub fn header(self, key: K, value: V) -> Self - where - HeaderName: TryFrom, - >::Error: Into, - HeaderValue: TryFrom, - >::Error: Into, - { - self.map(|builder| builder.header(key, value)) - } - - pub fn bearer_auth(self, token: T) -> Self - where - T: Display, - { - self.map(|builder| builder.bearer_auth(token)) - } - - pub fn json(self, value: &T) -> Self - where - T: ?Sized + Serialize, - { - self.map(|builder| builder.json(value)) - } - - pub async fn send(self) -> Result { - match self.builder.send().await { - Ok(response) => { - let request_ids = Self::extract_request_ids(&response); - tracing::debug!( - method = %self.method, - url = %self.url, - status = %response.status(), - request_ids = ?request_ids, - version = ?response.version(), - "Request completed" - ); - - Ok(response) - } - Err(error) => { - let status = error.status(); - tracing::debug!( - method = %self.method, - url = %self.url, - status = status.map(|s| s.as_u16()), - error = %error, - "Request failed" - ); - Err(error) - } - } - } - - fn extract_request_ids(response: &Response) -> HashMap { - ["cf-ray", "x-request-id", "x-oai-request-id"] - .iter() - .filter_map(|&name| { - let header_name = HeaderName::from_static(name); - let value = response.headers().get(header_name)?; - let value = value.to_str().ok()?.to_owned(); - Some((name.to_owned(), value)) - }) - .collect() - } -} #[derive(Debug, Clone)] pub struct Originator { pub value: String, diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 56e7f6cadb0..54756bda2d2 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,7 +6,6 @@ use crate::codex::TurnContext; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use crate::shell::Shell; -use crate::shell::default_user_shell; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -95,7 +94,7 @@ impl EnvironmentContext { && self.writable_roots == *writable_roots } - pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + pub fn diff(before: &TurnContext, after: &TurnContext, shell: &Shell) -> Self { let cwd = if before.cwd != after.cwd { Some(after.cwd.clone()) } else { @@ -111,18 +110,15 @@ impl EnvironmentContext { } else { None }; - EnvironmentContext::new(cwd, approval_policy, sandbox_policy, default_user_shell()) + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, shell.clone()) } -} -impl From<&TurnContext> for EnvironmentContext { - fn from(turn_context: &TurnContext) -> Self { + pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - // Shell is not configurable from turn to turn - default_user_shell(), + shell.clone(), ) } } @@ -201,6 +197,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, } } @@ -338,6 +335,7 @@ mod tests { Shell { shell_type: ShellType::Bash, shell_path: "/bin/bash".into(), + shell_snapshot: None, }, ); let context2 = EnvironmentContext::new( @@ -347,6 +345,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: "/bin/zsh".into(), + shell_snapshot: None, }, ); diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 6b4bed4db3a..6ab6291a4bb 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -13,6 +13,7 @@ use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; +use crate::user_instructions::SkillInstructions; use crate::user_instructions::UserInstructions; use crate::user_shell_command::is_user_shell_command_text; @@ -23,7 +24,9 @@ fn is_session_prefix(text: &str) -> bool { } fn parse_user_message(message: &[ContentItem]) -> Option { - if UserInstructions::is_user_instructions(message) { + if UserInstructions::is_user_instructions(message) + || SkillInstructions::is_skill_instructions(message) + { return None; } @@ -198,14 +201,22 @@ mod tests { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], }, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "echo 42".to_string(), - }], - }, - ]; + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\ndemo\nskills/demo/SKILL.md\nbody\n" + .to_string(), + }], + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "echo 42".to_string(), + }], + }, + ]; for item in items { let turn_item = parse_turn_item(&item); diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index ba1ac430040..596f325059d 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -28,6 +28,7 @@ use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxPermissions; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; @@ -55,7 +56,7 @@ pub struct ExecParams { pub cwd: PathBuf, pub expiration: ExecExpiration, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -144,7 +145,7 @@ pub async fn process_exec_tool_call( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0: _, } = params; @@ -162,7 +163,7 @@ pub async fn process_exec_tool_call( cwd, env, expiration, - with_escalated_permissions, + sandbox_permissions, justification, }; @@ -192,7 +193,7 @@ pub(crate) async fn execute_exec_env( env, expiration, sandbox, - with_escalated_permissions, + sandbox_permissions, justification, arg0, } = env; @@ -202,7 +203,7 @@ pub(crate) async fn execute_exec_env( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0, }; @@ -857,7 +858,7 @@ mod tests { cwd: std::env::current_dir()?, expiration: 500.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -902,7 +903,7 @@ mod tests { cwd: cwd.clone(), expiration: ExecExpiration::Cancellation(cancel_token), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 2d1c2efe5e8..6de2967c765 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -34,6 +34,13 @@ const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; const DEFAULT_POLICY_FILE: &str = "default.codexpolicy"; +fn is_policy_match(rule_match: &RuleMatch) -> bool { + match rule_match { + RuleMatch::PrefixRuleMatch { .. } => true, + RuleMatch::HeuristicsRuleMatch { .. } => false, + } +} + #[derive(Debug, Error)] pub enum ExecPolicyError { #[error("failed to read execpolicy files from {dir}: {source}")] @@ -147,49 +154,62 @@ pub(crate) async fn append_execpolicy_amendment_and_update( Ok(()) } -/// Returns a proposed execpolicy amendment only when heuristics caused -/// the prompt decision, so we can offer to apply that amendment for future runs. -/// -/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit -/// execpolicy rule also prompts, we return `None` because applying the amendment would not -/// skip that policy requirement. -/// -/// Examples: +/// Derive a proposed execpolicy amendment when a command requires user approval +/// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement. +/// - Otherwise return the first heuristics Prompt. +/// - Examples: /// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`. /// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`. /// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt /// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`. /// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above, /// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"]. -fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option { - if evaluation.decision != Decision::Prompt { +fn try_derive_execpolicy_amendment_for_prompt_rules( + matched_rules: &[RuleMatch], +) -> Option { + if matched_rules + .iter() + .any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt) + { return None; } - let mut first_prompt_from_heuristics: Option> = None; - for rule_match in &evaluation.matched_rules { - match rule_match { - RuleMatch::HeuristicsRuleMatch { command, decision } => { - if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() { - first_prompt_from_heuristics = Some(command.clone()); - } - } - _ if rule_match.decision() == Decision::Prompt => { - return None; - } - _ => {} - } + matched_rules + .iter() + .find_map(|rule_match| match rule_match { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + } => Some(ExecPolicyAmendment::from(command.clone())), + _ => None, + }) +} + +/// - Note: we only use this amendment when the command fails to run in sandbox and codex prompts the user to run outside the sandbox +/// - The purpose of this amendment is to bypass sandbox for similar commands in the future +/// - If any execpolicy rule matches, return None, because we would already be running command outside the sandbox +fn try_derive_execpolicy_amendment_for_allow_rules( + matched_rules: &[RuleMatch], +) -> Option { + if matched_rules.iter().any(is_policy_match) { + return None; } - first_prompt_from_heuristics.map(ExecPolicyAmendment::from) + matched_rules + .iter() + .find_map(|rule_match| match rule_match { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Allow, + } => Some(ExecPolicyAmendment::from(command.clone())), + _ => None, + }) } /// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision. fn derive_prompt_reason(evaluation: &Evaluation) -> Option { evaluation.matched_rules.iter().find_map(|rule_match| { - if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == Decision::Prompt - { + if is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt { Some(PROMPT_REASON.to_string()) } else { None @@ -215,10 +235,6 @@ pub(crate) async fn create_exec_approval_requirement_for_command( }; let policy = exec_policy.read().await; let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback); - let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| { - !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) - && rule_match.decision() == Decision::Allow - }); match evaluation.decision { Decision::Forbidden => ExecApprovalRequirement::Forbidden { @@ -233,7 +249,7 @@ pub(crate) async fn create_exec_approval_requirement_for_command( ExecApprovalRequirement::NeedsApproval { reason: derive_prompt_reason(&evaluation), proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { - proposed_execpolicy_amendment(&evaluation) + try_derive_execpolicy_amendment_for_prompt_rules(&evaluation.matched_rules) } else { None }, @@ -241,7 +257,15 @@ pub(crate) async fn create_exec_approval_requirement_for_command( } } Decision::Allow => ExecApprovalRequirement::Skip { - bypass_sandbox: has_policy_allow, + // Bypass sandbox if execpolicy allows the command + bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| { + is_policy_match(rule_match) && rule_match.decision() == Decision::Allow + }), + proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { + try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules) + } else { + None + }, }, } } @@ -730,4 +754,56 @@ prefix_rule(pattern=["rm"], decision="forbidden") } ); } + + #[tokio::test] + async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { + let command = vec!["echo".to_string(), "safe".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + } + ); + } + + #[tokio::test] + async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { + let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec!["echo".to_string(), "safe".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + } + ); + } } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 1d775360c41..a011884fc45 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -48,16 +48,20 @@ pub enum Feature { WebSearchRequest, /// Gate the execpolicy enforcement for shell/unified exec. ExecPolicy, - /// Enable the model-based risk assessments for sandboxed commands. - SandboxCommandAssessment, /// Enable Windows sandbox (restricted token) on Windows. WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) RemoteCompaction, + /// Refresh remote models and emit AppReady once the list is available. + RemoteModels, /// Allow model to call multiple tools in parallel (only for models supporting it). ParallelToolCalls, /// Experimental skills injection (CLI flag-driven). Skills, + /// Experimental shell snapshotting. + ShellSnapshot, + /// Experimental TUI v2 (viewport) implementation. + Tui2, } impl Feature { @@ -98,7 +102,6 @@ pub struct Features { pub struct FeatureOverrides { pub include_apply_patch_tool: Option, pub web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, } impl FeatureOverrides { @@ -190,7 +193,6 @@ impl Features { let mut features = Features::with_defaults(); let base_legacy = LegacyFeatureToggles { - experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, @@ -206,8 +208,6 @@ impl Features { let profile_legacy = LegacyFeatureToggles { include_apply_patch_tool: config_profile.include_apply_patch_tool, - experimental_sandbox_command_assessment: config_profile - .experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: config_profile .experimental_use_freeform_apply_patch, @@ -266,6 +266,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ParallelToolCalls, + key: "parallel", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ViewImageTool, key: "view_image_tool", @@ -315,12 +321,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, - FeatureSpec { - id: Feature::SandboxCommandAssessment, - key: "experimental_sandbox_command_assessment", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::WindowsSandbox, key: "enable_experimental_windows_sandbox", @@ -334,8 +334,8 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: true, }, FeatureSpec { - id: Feature::ParallelToolCalls, - key: "parallel", + id: Feature::RemoteModels, + key: "remote_models", stage: Stage::Experimental, default_enabled: false, }, @@ -345,4 +345,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::ShellSnapshot, + key: "shell_snapshot", + stage: Stage::Experimental, + default_enabled: false, + }, + FeatureSpec { + id: Feature::Tui2, + key: "tui2", + stage: Stage::Experimental, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 4d59f2a9a33..0c74d380e80 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,10 +9,6 @@ struct Alias { } const ALIASES: &[Alias] = &[ - Alias { - legacy_key: "experimental_sandbox_command_assessment", - feature: Feature::SandboxCommandAssessment, - }, Alias { legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, @@ -48,7 +44,6 @@ pub(crate) fn feature_for_key(key: &str) -> Option { #[derive(Debug, Default)] pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, - pub experimental_sandbox_command_assessment: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, @@ -64,12 +59,6 @@ impl LegacyFeatureToggles { self.include_apply_patch_tool, "include_apply_patch_tool", ); - set_if_some( - features, - Feature::SandboxCommandAssessment, - self.experimental_sandbox_command_assessment, - "experimental_sandbox_command_assessment", - ); set_if_some( features, Feature::ApplyPatchFreeform, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 721c6bb43ca..cd0ff497ab2 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -67,12 +67,12 @@ pub use conversation_manager::NewConversation; pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; -mod openai_model_info; pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; +pub mod shell_snapshot; pub mod skills; pub mod spawn; pub mod terminal; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 4912a64694b..aa865010143 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -99,7 +99,6 @@ pub struct ModelProviderInfo { } impl ModelProviderInfo { - #[allow(dead_code)] fn build_header_map(&self) -> crate::error::Result { let mut headers = HeaderMap::new(); if let Some(extra) = &self.http_headers { @@ -208,6 +207,45 @@ impl ModelProviderInfo { .map(Duration::from_millis) .unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS)) } + pub fn create_openai_provider() -> ModelProviderInfo { + ModelProviderInfo { + name: "OpenAI".into(), + // Allow users to override the default OpenAI endpoint by + // exporting `OPENAI_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or Azure-style deployment + // without requiring a full TOML override for the built-in + // OpenAI provider. + base_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] + .into_iter() + .collect(), + ), + env_http_headers: Some( + [ + ( + "OpenAI-Organization".to_string(), + "OPENAI_ORGANIZATION".to_string(), + ), + ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ] + .into_iter() + .collect(), + ), + // Use global defaults for retry/timeout unless overridden in config.toml. + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: true, + } + } } pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234; @@ -225,46 +263,7 @@ pub fn built_in_model_providers() -> HashMap { // open source ("oss") providers by default. Users are encouraged to add to // `model_providers` in config.toml to add their own providers. [ - ( - "openai", - P { - name: "OpenAI".into(), - // Allow users to override the default OpenAI endpoint by - // exporting `OPENAI_BASE_URL`. This is useful when pointing - // Codex at a proxy, mock server, or Azure-style deployment - // without requiring a full TOML override for the built-in - // OpenAI provider. - base_url: std::env::var("OPENAI_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some( - [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] - .into_iter() - .collect(), - ), - env_http_headers: Some( - [ - ( - "OpenAI-Organization".to_string(), - "OPENAI_ORGANIZATION".to_string(), - ), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), - ] - .into_iter() - .collect(), - ), - // Use global defaults for retry/timeout unless overridden in config.toml. - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - requires_openai_auth: true, - }, - ), + ("openai", P::create_openai_provider()), ( OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs deleted file mode 100644 index 4ee7d7187a9..00000000000 --- a/codex-rs/core/src/openai_model_info.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::openai_models::model_family::ModelFamily; - -// Shared constants for commonly used window/token sizes. -pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; - -/// Metadata about a model, particularly OpenAI models. -/// We may want to consider including details like the pricing for -/// input tokens, output tokens, etc., though users will need to be able to -/// override this in config.toml, as this information can get out of date. -/// Though this would help present more accurate pricing information in the UI. -#[derive(Debug)] -pub(crate) struct ModelInfo { - /// Size of the context window in tokens. This is the maximum size of the input context. - pub(crate) context_window: i64, - - /// Token threshold where we should automatically compact conversation history. This considers - /// input tokens + output tokens of this turn. - pub(crate) auto_compact_token_limit: Option, -} - -impl ModelInfo { - const fn new(context_window: i64) -> Self { - Self { - context_window, - auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), - } - } - - const fn default_auto_compact_limit(context_window: i64) -> i64 { - (context_window * 9) / 10 - } -} - -pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { - let slug = model_family.slug.as_str(); - match slug { - // OSS models have a 128k shared token pool. - // Arbitrarily splitting it: 3/4 input context, 1/4 output. - // https://openai.com/index/gpt-oss-model-card/ - "gpt-oss-20b" => Some(ModelInfo::new(96_000)), - "gpt-oss-120b" => Some(ModelInfo::new(96_000)), - // https://platform.openai.com/docs/models/o3 - "o3" => Some(ModelInfo::new(200_000)), - - // https://platform.openai.com/docs/models/o4-mini - "o4-mini" => Some(ModelInfo::new(200_000)), - - // https://platform.openai.com/docs/models/codex-mini-latest - "codex-mini-latest" => Some(ModelInfo::new(200_000)), - - // As of Jun 25, 2025, gpt-4.1 defaults to gpt-4.1-2025-04-14. - // https://platform.openai.com/docs/models/gpt-4.1 - "gpt-4.1" | "gpt-4.1-2025-04-14" => Some(ModelInfo::new(1_047_576)), - - // As of Jun 25, 2025, gpt-4o defaults to gpt-4o-2024-08-06. - // https://platform.openai.com/docs/models/gpt-4o - "gpt-4o" | "gpt-4o-2024-08-06" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-05-13 - "gpt-4o-2024-05-13" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-11-20 - "gpt-4o-2024-11-20" => Some(ModelInfo::new(128_000)), - - // https://platform.openai.com/docs/models/gpt-3.5-turbo - "gpt-3.5-turbo" => Some(ModelInfo::new(16_385)), - - _ if slug.starts_with("gpt-5-codex") - || slug.starts_with("gpt-5.1-codex") - || slug.starts_with("gpt-5.1-codex-max") => - { - Some(ModelInfo::new(CONTEXT_WINDOW_272K)) - } - - _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ if slug.starts_with("codex-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ if slug.starts_with("exp-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - - _ => None, - } -} diff --git a/codex-rs/core/src/openai_models/cache.rs b/codex-rs/core/src/openai_models/cache.rs new file mode 100644 index 00000000000..cac16cc8530 --- /dev/null +++ b/codex-rs/core/src/openai_models/cache.rs @@ -0,0 +1,56 @@ +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::openai_models::ModelInfo; +use serde::Deserialize; +use serde::Serialize; +use std::io; +use std::io::ErrorKind; +use std::path::Path; +use std::time::Duration; +use tokio::fs; + +/// Serialized snapshot of models and metadata cached on disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ModelsCache { + pub(crate) fetched_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) etag: Option, + pub(crate) models: Vec, +} + +impl ModelsCache { + /// Returns `true` when the cache entry has not exceeded the configured TTL. + pub(crate) fn is_fresh(&self, ttl: Duration) -> bool { + if ttl.is_zero() { + return false; + } + let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else { + return false; + }; + let age = Utc::now().signed_duration_since(self.fetched_at); + age <= ttl_duration + } +} + +/// Read and deserialize the cache file if it exists. +pub(crate) async fn load_cache(path: &Path) -> io::Result> { + match fs::read(path).await { + Ok(contents) => { + let cache = serde_json::from_slice(&contents) + .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; + Ok(Some(cache)) + } + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +/// Persist the cache contents to disk, creating parent directories as needed. +pub(crate) async fn save_cache(path: &Path, cache: &ModelsCache) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await?; + } + let json = serde_json::to_vec_pretty(cache) + .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; + fs::write(path, json).await +} diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index e7a8beddb13..a77438ebc98 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1,3 +1,4 @@ +mod cache; pub mod model_family; pub mod model_presets; pub mod models_manager; diff --git a/codex-rs/core/src/openai_models/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs index 6ee18ad9e34..87ea6a4fcbc 100644 --- a/codex-rs/core/src/openai_models/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -15,6 +15,7 @@ const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md"); const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md"); const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md"); +pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; /// A model family is a group of models that share certain characteristics. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -23,14 +24,20 @@ pub struct ModelFamily { /// "gpt-4.1-2025-04-14". pub slug: String, - /// The model family name, e.g. "gpt-4.1". Note this should able to be used - /// with [`crate::openai_model_info::get_model_info`]. + /// The model family name, e.g. "gpt-4.1". This string is used when deriving + /// default metadata for the family, such as context windows. pub family: String, /// True if the model needs additional instructions on how to use the /// "virtual" `apply_patch` CLI. pub needs_special_apply_patch_instructions: bool, + /// Maximum supported context window, if known. + pub context_window: Option, + + /// Token threshold for automatic compaction if config does not override it. + auto_compact_token_limit: Option, + // Whether the `reasoning` field can be set when making a request to this // model family. Note it has `effort` and `summary` subfields (though // `summary` is optional). @@ -82,6 +89,12 @@ impl ModelFamily { if let Some(reasoning_summary_format) = config.model_reasoning_summary_format.as_ref() { self.reasoning_summary_format = reasoning_summary_format.clone(); } + if let Some(context_window) = config.model_context_window { + self.context_window = Some(context_window); + } + if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit { + self.auto_compact_token_limit = Some(auto_compact_token_limit); + } self } pub fn with_remote_overrides(mut self, remote_models: Vec) -> Self { @@ -89,10 +102,24 @@ impl ModelFamily { if model.slug == self.slug { self.default_reasoning_effort = Some(model.default_reasoning_level); self.shell_type = model.shell_type; + self.base_instructions = model.base_instructions.unwrap_or(self.base_instructions); } } self } + + pub fn auto_compact_token_limit(&self) -> Option { + self.auto_compact_token_limit + .or(self.context_window.map(Self::default_auto_compact_limit)) + } + + const fn default_auto_compact_limit(context_window: i64) -> i64 { + (context_window * 9) / 10 + } + + pub fn get_model_slug(&self) -> &str { + &self.slug + } } macro_rules! model_family { @@ -105,6 +132,8 @@ macro_rules! model_family { slug: $slug.to_string(), family: $family.to_string(), needs_special_apply_patch_instructions: false, + context_window: Some(CONTEXT_WINDOW_272K), + auto_compact_token_limit: None, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, supports_parallel_tool_calls: false, @@ -136,12 +165,14 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { slug, "o3", supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, + context_window: Some(200_000), ) } else if slug.starts_with("o4-mini") { model_family!( slug, "o4-mini", supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, + context_window: Some(200_000), ) } else if slug.starts_with("codex-mini-latest") { model_family!( @@ -149,18 +180,32 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { supports_reasoning_summaries: true, needs_special_apply_patch_instructions: true, shell_type: ConfigShellToolType::Local, + context_window: Some(200_000), ) } else if slug.starts_with("gpt-4.1") { model_family!( slug, "gpt-4.1", needs_special_apply_patch_instructions: true, + context_window: Some(1_047_576), ) } else if slug.starts_with("gpt-oss") || slug.starts_with("openai/gpt-oss") { - model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function)) + model_family!( + slug, "gpt-oss", + apply_patch_tool_type: Some(ApplyPatchToolType::Function), + context_window: Some(96_000), + ) } else if slug.starts_with("gpt-4o") { - model_family!(slug, "gpt-4o", needs_special_apply_patch_instructions: true) + model_family!( + slug, "gpt-4o", + needs_special_apply_patch_instructions: true, + context_window: Some(128_000), + ) } else if slug.starts_with("gpt-3.5") { - model_family!(slug, "gpt-3.5", needs_special_apply_patch_instructions: true) + model_family!( + slug, "gpt-3.5", + needs_special_apply_patch_instructions: true, + context_window: Some(16_385), + ) } else if slug.starts_with("test-gpt-5") { model_family!( slug, slug, @@ -179,23 +224,20 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Tokens(10_000), ) - // Internal models. - } else if slug.starts_with("codex-exp-") { + // Experimental models. + } else if slug.starts_with("exp-codex") { + // Same as gpt-5.1-codex-max. model_family!( slug, slug, supports_reasoning_summaries: true, reasoning_summary_format: ReasoningSummaryFormat::Experimental, - base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), + base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), - experimental_supported_tools: vec![ - "grep_files".to_string(), - "list_dir".to_string(), - "read_file".to_string(), - ], shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, - support_verbosity: true, + support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("exp-") { model_family!( @@ -209,6 +251,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), shell_type: ConfigShellToolType::UnifiedExec, supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) // Production models. @@ -220,9 +263,10 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, + supports_parallel_tool_calls: false, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") @@ -235,9 +279,24 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), shell_type: ConfigShellToolType::ShellCommand, - supports_parallel_tool_calls: true, + supports_parallel_tool_calls: false, support_verbosity: false, truncation_policy: TruncationPolicy::Tokens(10_000), + context_window: Some(CONTEXT_WINDOW_272K), + ) + } else if slug.starts_with("robin") { + model_family!( + slug, slug, + supports_reasoning_summaries: true, + apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), + support_verbosity: true, + default_verbosity: Some(Verbosity::Low), + base_instructions: GPT_5_1_INSTRUCTIONS.to_string(), + default_reasoning_effort: Some(ReasoningEffort::Medium), + truncation_policy: TruncationPolicy::Bytes(10_000), + shell_type: ConfigShellToolType::ShellCommand, + supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5.1") { model_family!( @@ -251,6 +310,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { truncation_policy: TruncationPolicy::Bytes(10_000), shell_type: ConfigShellToolType::ShellCommand, supports_parallel_tool_calls: true, + context_window: Some(CONTEXT_WINDOW_272K), ) } else if slug.starts_with("gpt-5") { model_family!( @@ -260,6 +320,7 @@ pub fn find_family_for_model(slug: &str) -> ModelFamily { shell_type: ConfigShellToolType::Default, support_verbosity: true, truncation_policy: TruncationPolicy::Bytes(10_000), + context_window: Some(CONTEXT_WINDOW_272K), ) } else { derive_default_model_family(slug) @@ -271,6 +332,8 @@ fn derive_default_model_family(model: &str) -> ModelFamily { slug: model.to_string(), family: model.to_string(), needs_special_apply_patch_instructions: false, + context_window: None, + auto_compact_token_limit: None, supports_reasoning_summaries: false, reasoning_summary_format: ReasoningSummaryFormat::None, supports_parallel_tool_calls: false, @@ -291,6 +354,7 @@ mod tests { use super::*; use codex_protocol::openai_models::ClientVersion; use codex_protocol::openai_models::ModelVisibility; + use codex_protocol::openai_models::ReasoningEffortPreset; fn remote(slug: &str, effort: ReasoningEffort, shell: ConfigShellToolType) -> ModelInfo { ModelInfo { @@ -298,12 +362,17 @@ mod tests { display_name: slug.to_string(), description: Some(format!("{slug} desc")), default_reasoning_level: effort, - supported_reasoning_levels: vec![effort], + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort, + description: effort.to_string(), + }], shell_type: shell, visibility: ModelVisibility::List, minimal_client_version: ClientVersion(0, 1, 0), supported_in_api: true, priority: 1, + upgrade: None, + base_instructions: None, } } diff --git a/codex-rs/core/src/openai_models/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs index 6c004656948..b279a33fc7a 100644 --- a/codex-rs/core/src/openai_models/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -93,6 +93,34 @@ static PRESETS: Lazy> = Lazy::new(|| { }), show_in_picker: true, }, + ModelPreset { + id: "robin".to_string(), + model: "robin".to_string(), + display_name: "robin".to_string(), + description: "Robin".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffort::XHigh, + description: "Extra high reasoning for complex problems".to_string(), + }, + ], + is_default: false, + upgrade: None, + show_in_picker: true, + }, ModelPreset { id: "gpt-5.1".to_string(), model: "gpt-5.1".to_string(), diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index fd0fea362d4..de9aa0f7c87 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -1,94 +1,266 @@ +use chrono::Utc; use codex_api::ModelsClient; use codex_api::ReqwestTransport; +use codex_app_server_protocol::AuthMode; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelsResponse; use http::HeaderMap; +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; +use tokio::sync::TryLockError; +use tracing::error; +use super::cache; +use super::cache::ModelsCache; 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::Result as CoreResult; +use crate::features::Feature; use crate::model_provider_info::ModelProviderInfo; use crate::openai_models::model_family::ModelFamily; use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; +const MODEL_CACHE_FILE: &str = "models_cache.json"; +const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); +const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; +const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; + +/// Coordinates remote model discovery plus cached metadata on disk. #[derive(Debug)] pub struct ModelsManager { // todo(aibrahim) merge available_models and model family creation into one struct - pub available_models: RwLock>, - pub remote_models: RwLock>, - pub etag: String, - pub auth_manager: Arc, + available_models: RwLock>, + remote_models: RwLock>, + auth_manager: Arc, + etag: RwLock>, + codex_home: PathBuf, + cache_ttl: Duration, + provider: ModelProviderInfo, } impl ModelsManager { + /// Construct a manager scoped to the provided `AuthManager`. pub fn new(auth_manager: Arc) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); + Self { + available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), + remote_models: RwLock::new(Vec::new()), + auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider: ModelProviderInfo::create_openai_provider(), + } + } + + #[cfg(any(test, feature = "test-support"))] + /// Construct a manager scoped to the provided `AuthManager` with a specific provider. Used for integration tests. + pub fn with_provider(auth_manager: Arc, provider: ModelProviderInfo) -> Self { + let codex_home = auth_manager.codex_home().to_path_buf(); Self { available_models: RwLock::new(builtin_model_presets(auth_manager.get_auth_mode())), remote_models: RwLock::new(Vec::new()), - etag: String::new(), auth_manager, + etag: RwLock::new(None), + codex_home, + cache_ttl: DEFAULT_MODEL_CACHE_TTL, + provider, } } - // do not use this function yet. It's work in progress. - pub async fn refresh_available_models( - &self, - provider: &ModelProviderInfo, - ) -> CoreResult> { + /// Fetch the latest remote models, using the on-disk cache when still fresh. + pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> { + if !config.features.enabled(Feature::RemoteModels) { + return Ok(()); + } + if self.try_load_cache().await { + return Ok(()); + } + let auth = self.auth_manager.auth(); - let api_provider = provider.to_api_provider(auth.as_ref().map(|auth| auth.mode))?; - let api_auth = auth_provider_from_auth(auth.clone(), provider).await?; + let api_provider = self.provider.to_api_provider(Some(AuthMode::ChatGPT))?; + let api_auth = auth_provider_from_auth(auth.clone(), &self.provider).await?; let transport = ReqwestTransport::new(build_reqwest_client()); let client = ModelsClient::new(transport, api_provider, api_auth); - let response = client - .list_models(env!("CARGO_PKG_VERSION"), HeaderMap::new()) + let client_version = format_client_version_to_whole(); + let ModelsResponse { models, etag } = client + .list_models(&client_version, HeaderMap::new()) .await .map_err(map_api_error)?; - let models = response.models; - *self.remote_models.write().await = models.clone(); + let etag = (!etag.is_empty()).then_some(etag); + + self.apply_remote_models(models.clone()).await; + *self.etag.write().await = etag.clone(); + self.persist_cache(&models, etag).await; + Ok(()) + } + + pub async fn list_models(&self, config: &Config) -> Vec { + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } + self.available_models.read().await.clone() + } + + pub fn try_list_models(&self) -> Result, TryLockError> { + self.available_models + .try_read() + .map(|models| models.clone()) + } + + /// Look up the requested model family while applying remote metadata overrides. + pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + find_family_for_model(model) + .with_config_overrides(config) + .with_remote_overrides(self.remote_models.read().await.clone()) + } + + pub async fn get_model(&self, model: &Option, config: &Config) -> String { + if let Some(model) = model.as_ref() { + return model.to_string(); + } + if let Err(err) = self.refresh_available_models(config).await { + error!("failed to refresh available models: {err}"); + } + // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model + let auth_mode = self.auth_manager.get_auth_mode(); + if auth_mode == Some(AuthMode::ChatGPT) + && self + .available_models + .read() + .await + .iter() + .any(|m| m.model == CODEX_AUTO_BALANCED_MODEL) { - let mut available_models_guard = self.available_models.write().await; - *available_models_guard = self.build_available_models().await; + return CODEX_AUTO_BALANCED_MODEL.to_string(); } - Ok(models) + OPENAI_DEFAULT_MODEL.to_string() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_model_offline(model: Option<&str>) -> String { + model.unwrap_or(OPENAI_DEFAULT_MODEL).to_string() } - pub fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily { + #[cfg(any(test, feature = "test-support"))] + /// Offline helper that builds a `ModelFamily` without consulting remote state. + pub fn construct_model_family_offline(model: &str, config: &Config) -> ModelFamily { find_family_for_model(model).with_config_overrides(config) } - async fn build_available_models(&self) -> Vec { + /// 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; + self.build_available_models().await; + } + + /// Attempt to satisfy the refresh from the cache when it matches the provider and TTL. + async fn try_load_cache(&self) -> bool { + // todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk + let cache_path = self.cache_path(); + let cache = match cache::load_cache(&cache_path).await { + Ok(cache) => cache, + Err(err) => { + error!("failed to load models cache: {err}"); + return false; + } + }; + let cache = match cache { + Some(cache) => cache, + None => return false, + }; + if !cache.is_fresh(self.cache_ttl) { + return false; + } + let models = cache.models.clone(); + *self.etag.write().await = cache.etag.clone(); + self.apply_remote_models(models.clone()).await; + true + } + + /// Serialize the latest fetch to disk for reuse across future processes. + async fn persist_cache(&self, models: &[ModelInfo], etag: Option) { + let cache = ModelsCache { + fetched_at: Utc::now(), + etag, + models: models.to_vec(), + }; + let cache_path = self.cache_path(); + if let Err(err) = cache::save_cache(&cache_path, &cache).await { + error!("failed to write models cache: {err}"); + } + } + + /// Convert remote model metadata into picker-ready presets, marking defaults. + async fn build_available_models(&self) { let mut available_models = self.remote_models.read().await.clone(); available_models.sort_by(|a, b| b.priority.cmp(&a.priority)); - let mut model_presets: Vec = - available_models.into_iter().map(Into::into).collect(); + let mut model_presets: Vec = available_models + .into_iter() + .map(Into::into) + .filter(|preset: &ModelPreset| preset.show_in_picker) + .collect(); if let Some(default) = model_presets.first_mut() { default.is_default = true; } - model_presets + { + let mut available_models_guard = self.available_models.write().await; + *available_models_guard = model_presets; + } + } + + fn cache_path(&self) -> PathBuf { + self.codex_home.join(MODEL_CACHE_FILE) + } +} + +/// Convert a client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3") +fn format_client_version_to_whole() -> String { + format_client_version_from_parts( + env!("CARGO_PKG_VERSION_MAJOR"), + env!("CARGO_PKG_VERSION_MINOR"), + env!("CARGO_PKG_VERSION_PATCH"), + ) +} + +fn format_client_version_from_parts(major: &str, minor: &str, patch: &str) -> String { + const DEV_VERSION: &str = "0.0.0"; + const FALLBACK_VERSION: &str = "99.99.99"; + + let normalized = format!("{major}.{minor}.{patch}"); + + if normalized == DEV_VERSION { + FALLBACK_VERSION.to_string() + } else { + normalized } } #[cfg(test)] mod tests { + use super::cache::ModelsCache; use super::*; use crate::CodexAuth; + use crate::auth::AuthCredentialsStoreMode; + use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::features::Feature; use crate::model_provider_info::WireApi; use codex_protocol::openai_models::ModelsResponse; + use core_test_support::responses::mount_models_once; use serde_json::json; - use wiremock::Mock; + use tempfile::tempdir; use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo { serde_json::from_value(json!({ @@ -96,12 +268,14 @@ mod tests { "display_name": display, "description": format!("{display} desc"), "default_reasoning_level": "medium", - "supported_reasoning_levels": ["low", "medium"], + "supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}], "shell_type": "shell_command", "visibility": "list", "minimal_client_version": [0, 1, 0], "supported_in_api": true, - "priority": priority + "priority": priority, + "upgrade": null, + "base_instructions": null, })) .expect("valid model") } @@ -131,35 +305,36 @@ mod tests { remote_model("priority-low", "Low", 1), remote_model("priority-high", "High", 10), ]; - let response = ModelsResponse { - models: remote_models.clone(), - }; - Mock::given(method("GET")) - .and(path("/models")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "application/json") - .set_body_json(&response), - ) - .expect(1) - .mount(&server) - .await; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + etag: String::new(), + }, + ) + .await; + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let manager = ModelsManager::new(auth_manager); let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); - let returned = manager - .refresh_available_models(&provider) + manager + .refresh_available_models(&config) .await .expect("refresh succeeds"); - - assert_eq!(returned, remote_models); let cached_remote = manager.remote_models.read().await.clone(); assert_eq!(cached_remote, remote_models); - let available = manager.available_models.read().await.clone(); + let available = manager.list_models(&config).await; assert_eq!(available.len(), 2); assert_eq!(available[0].model, "priority-high"); assert!( @@ -168,5 +343,142 @@ mod tests { ); assert_eq!(available[1].model, "priority-low"); assert!(!available[1].is_default); + assert_eq!( + models_mock.requests().len(), + 1, + "expected a single /models request" + ); + } + + #[tokio::test] + async fn refresh_available_models_uses_cache_when_fresh() { + let server = MockServer::start().await; + let remote_models = vec![remote_model("cached", "Cached", 5)]; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: remote_models.clone(), + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); + + manager + .refresh_available_models(&config) + .await + .expect("first refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + remote_models, + "remote cache should store fetched models" + ); + + // Second call should read from cache and avoid the network. + manager + .refresh_available_models(&config) + .await + .expect("cached refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + remote_models, + "cache path should not mutate stored models" + ); + assert_eq!( + models_mock.requests().len(), + 1, + "cache hit should avoid a second /models request" + ); + } + + #[tokio::test] + async fn refresh_available_models_refetches_when_cache_stale() { + let server = MockServer::start().await; + let initial_models = vec![remote_model("stale", "Stale", 1)]; + let initial_mock = mount_models_once( + &server, + ModelsResponse { + models: initial_models.clone(), + etag: String::new(), + }, + ) + .await; + + let codex_home = tempdir().expect("temp dir"); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("load default test config"); + config.features.enable(Feature::RemoteModels); + let auth_manager = Arc::new(AuthManager::new( + codex_home.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + )); + let provider = provider_for(server.uri()); + let manager = ModelsManager::with_provider(auth_manager, provider); + + manager + .refresh_available_models(&config) + .await + .expect("initial refresh succeeds"); + + // Rewrite cache with an old timestamp so it is treated as stale. + let cache_path = codex_home.path().join(MODEL_CACHE_FILE); + let contents = + std::fs::read_to_string(&cache_path).expect("cache file should exist after refresh"); + let mut cache: ModelsCache = + serde_json::from_str(&contents).expect("cache should deserialize"); + cache.fetched_at = Utc::now() - chrono::Duration::hours(1); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache).unwrap()) + .expect("cache rewrite succeeds"); + + let updated_models = vec![remote_model("fresh", "Fresh", 9)]; + server.reset().await; + let refreshed_mock = mount_models_once( + &server, + ModelsResponse { + models: updated_models.clone(), + etag: String::new(), + }, + ) + .await; + + manager + .refresh_available_models(&config) + .await + .expect("second refresh succeeds"); + assert_eq!( + *manager.remote_models.read().await, + updated_models, + "stale cache should trigger refetch" + ); + assert_eq!( + initial_mock.requests().len(), + 1, + "initial refresh should only hit /models once" + ); + assert_eq!( + refreshed_mock.requests().len(), + 1, + "stale cache refresh should fetch /models once" + ); } } diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index f3353470427..399513f5ae0 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -117,9 +117,6 @@ mod tests { query: None, path: None, }, - ParsedCommand::Unknown { - cmd: "head -n 40".to_string(), - }, ], ); } @@ -143,16 +140,11 @@ mod tests { let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), - query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 200".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), + query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), + path: None, + }], ); } @@ -174,16 +166,11 @@ mod tests { let inner = "rg --files | head -n 50"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 50".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -273,6 +260,19 @@ mod tests { ); } + #[test] + fn supports_head_file_only() { + let inner = "head Cargo.toml"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), + }], + ); + } + #[test] fn supports_cat_sed_n() { let inner = "cat tui/Cargo.toml | sed -n '1,200p'"; @@ -313,6 +313,19 @@ mod tests { ); } + #[test] + fn supports_tail_file_only() { + let inner = "tail README.md"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "README.md".to_string(), + path: PathBuf::from("README.md"), + }], + ); + } + #[test] fn supports_npm_run_build_is_unknown() { assert_parsed( @@ -391,6 +404,19 @@ mod tests { ); } + #[test] + fn supports_single_string_script_with_cd_and_pipe() { + let inner = r#"cd /Users/pakrym/code/codex && rg -n "codex_api" codex-rs -S | head -n 50"#; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Search { + cmd: "rg -n codex_api codex-rs -S".to_string(), + query: Some("codex_api".to_string()), + path: Some("codex-rs".to_string()), + }], + ); + } + // ---- is_small_formatting_command unit tests ---- #[test] fn small_formatting_always_true_commands() { @@ -408,38 +434,43 @@ mod tests { fn head_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["head"]))); - // Numeric count only -> not considered small formatting by implementation - assert!(!is_small_formatting_command(&shlex_split_safe( - "head -n 40" - ))); + // Numeric count only -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("head -n 40"))); // With explicit file -> not small formatting assert!(!is_small_formatting_command(&shlex_split_safe( "head -n 40 file.txt" ))); - // File only (no count) -> treated as small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["head", "file.txt"]))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "head", "file.txt" + ]))); } #[test] fn tail_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["tail"]))); - // Numeric with plus offset -> not small formatting - assert!(!is_small_formatting_command(&shlex_split_safe( + // Numeric with plus offset -> formatting + assert!(is_small_formatting_command(&shlex_split_safe( "tail -n +10" ))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n +10 file.txt" ))); - // Numeric count - assert!(!is_small_formatting_command(&shlex_split_safe( - "tail -n 30" - ))); + // Numeric count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -n 30"))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n 30 file.txt" ))); - // File only -> small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["tail", "file.txt"]))); + // Byte count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -c 30"))); + assert!(is_small_formatting_command(&shlex_split_safe( + "tail -c +10" + ))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "tail", "file.txt" + ]))); } #[test] @@ -714,20 +745,15 @@ mod tests { #[test] fn bash_dash_c_pipeline_parsing() { - // Ensure -c is handled similarly to -lc by normalization + // Ensure -c is handled similarly to -lc by shell parsing let inner = "rg --files | head -n 1"; assert_parsed( - &shlex_split_safe(inner), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 1".to_string(), - }, - ], + &vec_str(&["bash", "-c", inner]), + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -1384,13 +1410,50 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { // Treat as formatting when no explicit file operand is present. // Common forms: `head -n 40`, `head -c 100`. // Keep cases like `head -n 40 file`. - tokens.len() < 3 + match tokens { + // `head` + [_] => true, + // `head ` or `head -n50`/`head -c100` + [_, arg] => arg.starts_with('-'), + // `head -n 40` / `head -c 100` (no file operand) + [_, flag, count] + if (flag == "-n" || flag == "-c") + && count.chars().all(|c| c.is_ascii_digit()) => + { + true + } + _ => false, + } } "tail" => { // Treat as formatting when no explicit file operand is present. - // Common forms: `tail -n +10`, `tail -n 30`. + // Common forms: `tail -n +10`, `tail -n 30`, `tail -c 100`. // Keep cases like `tail -n 30 file`. - tokens.len() < 3 + match tokens { + // `tail` + [_] => true, + // `tail ` or `tail -n30`/`tail -n+10` + [_, arg] => arg.starts_with('-'), + // `tail -n 30` / `tail -n +10` (no file operand) + [_, flag, count] + if flag == "-n" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + // `tail -c 100` / `tail -c +10` (no file operand) + [_, flag, count] + if flag == "-c" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + _ => false, + } } "sed" => { // Keep `sed -n file` (treated as a file read elsewhere); @@ -1543,6 +1606,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } @@ -1587,6 +1660,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 43a0034801a..cd05520110f 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -15,7 +15,7 @@ use crate::config::Config; use crate::features::Feature; -use crate::skills::load_skills; +use crate::skills::SkillMetadata; use crate::skills::render_skills_section; use dunce::canonicalize as normalize_path; use std::path::PathBuf; @@ -33,17 +33,12 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Combines `Config::instructions` and `AGENTS.md` (if present) into a single /// string of instructions. -pub(crate) async fn get_user_instructions(config: &Config) -> Option { +pub(crate) async fn get_user_instructions( + config: &Config, + skills: Option<&[SkillMetadata]>, +) -> Option { let skills_section = if config.features.enabled(Feature::Skills) { - let skills_outcome = load_skills(config); - for err in &skills_outcome.errors { - error!( - "failed to load skill {}: {}", - err.path.display(), - err.message - ); - } - render_skills_section(&skills_outcome.skills) + skills.and_then(render_skills_section) } else { None }; @@ -244,6 +239,7 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::skills::load_skills; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -289,7 +285,7 @@ mod tests { async fn no_doc_file_returns_none() { let tmp = tempfile::tempdir().expect("tempdir"); - let res = get_user_instructions(&make_config(&tmp, 4096, None)).await; + let res = get_user_instructions(&make_config(&tmp, 4096, None), None).await; assert!( res.is_none(), "Expected None when AGENTS.md is absent and no system instructions provided" @@ -303,7 +299,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap(); - let res = get_user_instructions(&make_config(&tmp, 4096, None)) + let res = get_user_instructions(&make_config(&tmp, 4096, None), None) .await .expect("doc expected"); @@ -322,7 +318,7 @@ mod tests { let huge = "A".repeat(LIMIT * 2); // 2 KiB fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap(); - let res = get_user_instructions(&make_config(&tmp, LIMIT, None)) + let res = get_user_instructions(&make_config(&tmp, LIMIT, None), None) .await .expect("doc expected"); @@ -354,7 +350,9 @@ mod tests { let mut cfg = make_config(&repo, 4096, None); cfg.cwd = nested; - let res = get_user_instructions(&cfg).await.expect("doc expected"); + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); assert_eq!(res, "root level doc"); } @@ -364,7 +362,7 @@ mod tests { let tmp = tempfile::tempdir().expect("tempdir"); fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); - let res = get_user_instructions(&make_config(&tmp, 0, None)).await; + let res = get_user_instructions(&make_config(&tmp, 0, None), None).await; assert!( res.is_none(), "With limit 0 the function should return None" @@ -380,7 +378,7 @@ mod tests { const INSTRUCTIONS: &str = "base instructions"; - let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS))) + let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)), None) .await .expect("should produce a combined instruction string"); @@ -397,7 +395,7 @@ mod tests { const INSTRUCTIONS: &str = "some instructions"; - let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS))).await; + let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)), None).await; assert_eq!(res, Some(INSTRUCTIONS.to_string())); } @@ -426,7 +424,9 @@ mod tests { let mut cfg = make_config(&repo, 4096, None); cfg.cwd = nested; - let res = get_user_instructions(&cfg).await.expect("doc expected"); + let res = get_user_instructions(&cfg, None) + .await + .expect("doc expected"); assert_eq!(res, "root doc\n\ncrate doc"); } @@ -439,7 +439,7 @@ mod tests { let cfg = make_config(&tmp, 4096, None); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("local doc expected"); @@ -461,7 +461,7 @@ mod tests { let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("fallback doc expected"); @@ -477,7 +477,7 @@ mod tests { let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]); - let res = get_user_instructions(&cfg) + let res = get_user_instructions(&cfg, None) .await .expect("AGENTS.md should win"); @@ -506,9 +506,13 @@ mod tests { "extract from pdfs", ); - let res = get_user_instructions(&cfg) - .await - .expect("instructions expected"); + let skills = load_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + ) + .await + .expect("instructions expected"); let expected_path = dunce::canonicalize( cfg.codex_home .join("skills/pdf-processing/SKILL.md") @@ -529,9 +533,13 @@ mod tests { let cfg = make_config(&tmp, 4096, None); create_skill(cfg.codex_home.clone(), "linting", "run clippy"); - let res = get_user_instructions(&cfg) - .await - .expect("instructions expected"); + let skills = load_skills(&cfg); + let res = get_user_instructions( + &cfg, + skills.errors.is_empty().then_some(skills.skills.as_slice()), + ) + .await + .expect("instructions expected"); let expected_path = dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f93364..fc6e4b9afd2 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -62,6 +62,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs deleted file mode 100644 index 8a34a933288..00000000000 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ /dev/null @@ -1,266 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use crate::AuthManager; -use crate::ModelProviderInfo; -use crate::client::ModelClient; -use crate::client_common::Prompt; -use crate::client_common::ResponseEvent; -use crate::config::Config; -use crate::openai_models::models_manager::ModelsManager; -use crate::protocol::SandboxPolicy; -use askama::Template; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::ConversationId; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SandboxCommandAssessment; -use codex_protocol::protocol::SessionSource; -use futures::StreamExt; -use serde_json::json; -use tokio::time::timeout; -use tracing::warn; - -const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(15); -const SANDBOX_ASSESSMENT_REASONING_EFFORT: ReasoningEffortConfig = ReasoningEffortConfig::Medium; - -#[derive(Template)] -#[template(path = "sandboxing/assessment_prompt.md", escape = "none")] -struct SandboxAssessmentPromptTemplate<'a> { - platform: &'a str, - sandbox_policy: &'a str, - filesystem_roots: Option<&'a str>, - working_directory: &'a str, - command_argv: &'a str, - command_joined: &'a str, - sandbox_failure_message: Option<&'a str>, -} - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn assess_command( - config: Arc, - provider: ModelProviderInfo, - auth_manager: Arc, - parent_otel: &OtelEventManager, - conversation_id: ConversationId, - models_manager: Arc, - session_source: SessionSource, - call_id: &str, - command: &[String], - sandbox_policy: &SandboxPolicy, - cwd: &Path, - failure_message: Option<&str>, -) -> Option { - if !config.experimental_sandbox_command_assessment || command.is_empty() { - return None; - } - - let command_json = serde_json::to_string(command).unwrap_or_else(|_| "[]".to_string()); - let command_joined = - shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); - let failure = failure_message - .map(str::trim) - .filter(|msg| !msg.is_empty()) - .map(str::to_string); - - let cwd_str = cwd.to_string_lossy().to_string(); - let sandbox_summary = summarize_sandbox_policy(sandbox_policy); - let mut roots = sandbox_roots_for_prompt(sandbox_policy, cwd); - roots.sort(); - roots.dedup(); - - let platform = std::env::consts::OS; - let roots_formatted = roots.iter().map(|root| root.to_string_lossy().to_string()); - let filesystem_roots = match roots_formatted.collect::>() { - collected if collected.is_empty() => None, - collected => Some(collected.join(", ")), - }; - - let prompt_template = SandboxAssessmentPromptTemplate { - platform, - sandbox_policy: sandbox_summary.as_str(), - filesystem_roots: filesystem_roots.as_deref(), - working_directory: cwd_str.as_str(), - command_argv: command_json.as_str(), - command_joined: command_joined.as_str(), - sandbox_failure_message: failure.as_deref(), - }; - let rendered_prompt = match prompt_template.render() { - Ok(rendered) => rendered, - Err(err) => { - warn!("failed to render sandbox assessment prompt: {err}"); - return None; - } - }; - let (system_prompt_section, user_prompt_section) = match rendered_prompt.split_once("\n---\n") { - Some(split) => split, - None => { - warn!("rendered sandbox assessment prompt missing separator"); - return None; - } - }; - let system_prompt = system_prompt_section - .strip_prefix("System Prompt:\n") - .unwrap_or(system_prompt_section) - .trim() - .to_string(); - let user_prompt = user_prompt_section - .strip_prefix("User Prompt:\n") - .unwrap_or(user_prompt_section) - .trim() - .to_string(); - - let prompt = Prompt { - input: vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: user_prompt }], - }], - tools: Vec::new(), - parallel_tool_calls: false, - base_instructions_override: Some(system_prompt), - output_schema: Some(sandbox_assessment_schema()), - }; - - let model_family = models_manager.construct_model_family(&config.model, &config); - - let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); - - let client = ModelClient::new( - Arc::clone(&config), - Some(auth_manager), - model_family, - child_otel, - provider, - Some(SANDBOX_ASSESSMENT_REASONING_EFFORT), - config.model_reasoning_summary, - conversation_id, - session_source, - ); - - let start = Instant::now(); - let assessment_result = timeout(SANDBOX_ASSESSMENT_TIMEOUT, async move { - let mut stream = client.stream(&prompt).await?; - let mut last_json: Option = None; - while let Some(event) = stream.next().await { - match event { - Ok(ResponseEvent::OutputItemDone(item)) => { - if let Some(text) = response_item_text(&item) { - last_json = Some(text); - } - } - Ok(ResponseEvent::RateLimits(_)) => {} - Ok(ResponseEvent::Completed { .. }) => break, - Ok(_) => continue, - Err(err) => return Err(err), - } - } - Ok(last_json) - }) - .await; - let duration = start.elapsed(); - parent_otel.sandbox_assessment_latency(call_id, duration); - - match assessment_result { - Ok(Ok(Some(raw))) => match serde_json::from_str::(raw.trim()) { - Ok(assessment) => { - parent_otel.sandbox_assessment( - call_id, - "success", - Some(assessment.risk_level), - duration, - ); - return Some(assessment); - } - Err(err) => { - warn!("failed to parse sandbox assessment JSON: {err}"); - parent_otel.sandbox_assessment(call_id, "parse_error", None, duration); - } - }, - Ok(Ok(None)) => { - warn!("sandbox assessment response did not include any message"); - parent_otel.sandbox_assessment(call_id, "no_output", None, duration); - } - Ok(Err(err)) => { - warn!("sandbox assessment failed: {err}"); - parent_otel.sandbox_assessment(call_id, "model_error", None, duration); - } - Err(_) => { - warn!("sandbox assessment timed out"); - parent_otel.sandbox_assessment(call_id, "timeout", None, duration); - } - } - - None -} - -fn summarize_sandbox_policy(policy: &SandboxPolicy) -> String { - match policy { - SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), - SandboxPolicy::WorkspaceWrite { network_access, .. } => { - let network = if *network_access { - "network" - } else { - "no-network" - }; - format!("workspace-write (network_access={network})") - } - } -} - -fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec { - let mut roots = vec![cwd.to_path_buf()]; - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - roots.extend(writable_roots.iter().cloned()); - } - roots -} - -fn sandbox_assessment_schema() -> serde_json::Value { - json!({ - "type": "object", - "required": ["description", "risk_level"], - "properties": { - "description": { - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "risk_level": { - "type": "string", - "enum": ["low", "medium", "high"] - }, - }, - "additionalProperties": false - }) -} - -fn response_item_text(item: &ResponseItem) -> Option { - match item { - ResponseItem::Message { content, .. } => { - let mut buffers: Vec<&str> = Vec::new(); - for segment in content { - match segment { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - if !text.is_empty() { - buffers.push(text); - } - } - ContentItem::InputImage { .. } => {} - } - } - if buffers.is_empty() { - None - } else { - Some(buffers.join("\n")) - } - } - ResponseItem::FunctionCallOutput { output, .. } => Some(output.content.clone()), - _ => None, - } -} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d43646021ee..5d719a79229 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,8 +6,6 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ -pub mod assessment; - use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; @@ -23,32 +21,11 @@ use crate::seatbelt::create_seatbelt_command_args; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; +pub use codex_protocol::models::SandboxPermissions; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SandboxPermissions { - UseDefault, - RequireEscalated, -} - -impl SandboxPermissions { - pub fn requires_escalated_permissions(self) -> bool { - matches!(self, SandboxPermissions::RequireEscalated) - } -} - -impl From for SandboxPermissions { - fn from(with_escalated_permissions: bool) -> Self { - if with_escalated_permissions { - SandboxPermissions::RequireEscalated - } else { - SandboxPermissions::UseDefault - } - } -} - #[derive(Debug)] pub struct CommandSpec { pub program: String, @@ -56,7 +33,7 @@ pub struct CommandSpec { pub cwd: PathBuf, pub env: HashMap, pub expiration: ExecExpiration, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -67,7 +44,7 @@ pub struct ExecEnv { pub env: HashMap, pub expiration: ExecExpiration, pub sandbox: SandboxType, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -181,7 +158,7 @@ impl SandboxManager { env, expiration: spec.expiration, sandbox, - with_escalated_permissions: spec.with_escalated_permissions, + sandbox_permissions: spec.sandbox_permissions, justification: spec.justification, arg0: arg0_override, }) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ac115facb69..608d8063239 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,6 +1,9 @@ use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; +use std::sync::Arc; + +use crate::shell_snapshot::ShellSnapshot; #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum ShellType { @@ -15,6 +18,8 @@ pub enum ShellType { pub struct Shell { pub(crate) shell_type: ShellType, pub(crate) shell_path: PathBuf, + #[serde(skip_serializing, skip_deserializing, default)] + pub(crate) shell_snapshot: Option>, } impl Shell { @@ -58,6 +63,33 @@ impl Shell { } } } + + pub(crate) fn wrap_command_with_snapshot(&self, command: &[String]) -> Vec { + let Some(snapshot) = &self.shell_snapshot else { + return command.to_vec(); + }; + + if command.is_empty() { + return command.to_vec(); + } + + match self.shell_type { + ShellType::Zsh | ShellType::Bash | ShellType::Sh => { + let mut args = self.derive_exec_args(". \"$0\" && exec \"$@\"", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::PowerShell => { + let mut args = + self.derive_exec_args("param($snapshot) . $snapshot; & @args", false); + args.push(snapshot.path.to_string_lossy().to_string()); + args.extend_from_slice(command); + args + } + ShellType::Cmd => command.to_vec(), + } + } } #[cfg(unix)] @@ -134,6 +166,7 @@ fn get_zsh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Zsh, shell_path, + shell_snapshot: None, }) } @@ -143,6 +176,7 @@ fn get_bash_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Bash, shell_path, + shell_snapshot: None, }) } @@ -152,6 +186,7 @@ fn get_sh_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Sh, shell_path, + shell_snapshot: None, }) } @@ -167,6 +202,7 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::PowerShell, shell_path, + shell_snapshot: None, }) } @@ -176,6 +212,7 @@ fn get_cmd_shell(path: Option<&PathBuf>) -> Option { shell_path.map(|shell_path| Shell { shell_type: ShellType::Cmd, shell_path, + shell_snapshot: None, }) } @@ -184,11 +221,13 @@ fn ultimate_fallback_shell() -> Shell { Shell { shell_type: ShellType::Cmd, shell_path: PathBuf::from("cmd.exe"), + shell_snapshot: None, } } else { Shell { shell_type: ShellType::Sh, shell_path: PathBuf::from("/bin/sh"), + shell_snapshot: None, } } } @@ -408,6 +447,51 @@ mod tests { } } + #[test] + fn derive_exec_args() { + let test_bash_shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, + }; + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", false), + vec!["/bin/bash", "-c", "echo hello"] + ); + assert_eq!( + test_bash_shell.derive_exec_args("echo hello", true), + vec!["/bin/bash", "-lc", "echo hello"] + ); + + let test_zsh_shell = Shell { + shell_type: ShellType::Zsh, + shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, + }; + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", false), + vec!["/bin/zsh", "-c", "echo hello"] + ); + assert_eq!( + test_zsh_shell.derive_exec_args("echo hello", true), + vec!["/bin/zsh", "-lc", "echo hello"] + ); + + let test_powershell_shell = Shell { + shell_type: ShellType::PowerShell, + shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, + }; + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", false), + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"] + ); + assert_eq!( + test_powershell_shell.derive_exec_args("echo hello", true), + vec!["pwsh.exe", "-Command", "echo hello"] + ); + } + #[tokio::test] async fn test_current_shell_detects_zsh() { let shell = Command::new("sh") @@ -423,6 +507,7 @@ mod tests { Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from(shell_path), + shell_snapshot: None, } ); } diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs new file mode 100644 index 00000000000..2c4c423f5c8 --- /dev/null +++ b/codex-rs/core/src/shell_snapshot.rs @@ -0,0 +1,463 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use crate::shell::Shell; +use crate::shell::ShellType; +use crate::shell::get_shell; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use tokio::fs; +use tokio::process::Command; +use tokio::time::timeout; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShellSnapshot { + pub path: PathBuf, +} + +const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); + +impl ShellSnapshot { + pub async fn try_new(codex_home: &Path, shell: &Shell) -> Option { + let extension = match shell.shell_type { + ShellType::PowerShell => "ps1", + _ => "sh", + }; + let path = + codex_home + .join("shell_snapshots") + .join(format!("{}.{}", Uuid::new_v4(), extension)); + match write_shell_snapshot(shell.shell_type.clone(), &path).await { + Ok(path) => { + tracing::info!("Shell snapshot successfully created: {}", path.display()); + Some(Self { path }) + } + Err(err) => { + tracing::warn!( + "Failed to create shell snapshot for {}: {err:?}", + shell.name() + ); + None + } + } + } +} + +impl Drop for ShellSnapshot { + fn drop(&mut self) { + if let Err(err) = std::fs::remove_file(&self.path) { + tracing::warn!( + "Failed to delete shell snapshot at {:?}: {err:?}", + self.path + ); + } + } +} + +pub 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:?}"); + } + let shell = get_shell(shell_type.clone(), None) + .with_context(|| format!("No available shell for {shell_type:?}"))?; + + let raw_snapshot = capture_snapshot(&shell).await?; + let snapshot = strip_snapshot_preamble(&raw_snapshot)?; + + if let Some(parent) = output_path.parent() { + let parent_display = parent.display(); + fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create snapshot parent {parent_display}"))?; + } + + let snapshot_path = output_path.display(); + fs::write(output_path, snapshot) + .await + .with_context(|| format!("Failed to write snapshot to {snapshot_path}"))?; + + Ok(output_path.to_path_buf()) +} + +async fn capture_snapshot(shell: &Shell) -> Result { + let shell_type = shell.shell_type.clone(); + match shell_type { + ShellType::Zsh => run_shell_script(shell, zsh_snapshot_script()).await, + ShellType::Bash => run_shell_script(shell, bash_snapshot_script()).await, + ShellType::Sh => run_shell_script(shell, sh_snapshot_script()).await, + ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await, + ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"), + } +} + +fn strip_snapshot_preamble(snapshot: &str) -> Result { + let marker = "# Snapshot file"; + let Some(start) = snapshot.find(marker) else { + bail!("Snapshot output missing marker {marker}"); + }; + + 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 run_shell_script_with_timeout( + shell: &Shell, + script: &str, + snapshot_timeout: Duration, +) -> Result { + let args = shell.derive_exec_args(script, true); + let shell_name = shell.name(); + + // Handler is kept as guard to control the drop. The `mut` pattern is required because .args() + // returns a ref of handler. + let mut handler = Command::new(&args[0]); + handler.args(&args[1..]); + handler.kill_on_drop(true); + let output = timeout(snapshot_timeout, handler.output()) + .await + .map_err(|_| anyhow!("Snapshot command timed out for {shell_name}"))? + .with_context(|| format!("Failed to execute {shell_name}"))?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("Snapshot command exited with status {status}: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn zsh_snapshot_script() -> &'static str { + r##"print '# Snapshot file' +print '# Unset all aliases to avoid conflicts with functions' +print 'unalias -a 2>/dev/null || true' +print '# Functions' +functions +print '' +setopt_count=$(setopt | wc -l | tr -d ' ') +print "# setopts $setopt_count" +setopt | sed 's/^/setopt /' +print '' +alias_count=$(alias -L | wc -l | tr -d ' ') +print "# aliases $alias_count" +alias -L +print '' +export_count=$(export -p | wc -l | tr -d ' ') +print "# exports $export_count" +export -p +"## +} + +fn bash_snapshot_script() -> &'static str { + r##"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +declare -f +echo '' +bash_opts=$(set -o | awk '$2=="on"{print $1}') +bash_opt_count=$(printf '%s\n' "$bash_opts" | sed '/^$/d' | wc -l | tr -d ' ') +echo "# setopts $bash_opt_count" +if [ -n "$bash_opts" ]; then + printf 'set -o %s\n' $bash_opts +fi +echo '' +alias_count=$(alias -p | wc -l | tr -d ' ') +echo "# aliases $alias_count" +alias -p +echo '' +export_count=$(export -p | wc -l | tr -d ' ') +echo "# exports $export_count" +export -p +"## +} + +fn sh_snapshot_script() -> &'static str { + r##"echo '# Snapshot file' +echo '# Unset all aliases to avoid conflicts with functions' +unalias -a 2>/dev/null || true +echo '# Functions' +if command -v typeset >/dev/null 2>&1; then + typeset -f +elif command -v declare >/dev/null 2>&1; then + declare -f +fi +echo '' +if set -o >/dev/null 2>&1; then + sh_opts=$(set -o | awk '$2=="on"{print $1}') + sh_opt_count=$(printf '%s\n' "$sh_opts" | sed '/^$/d' | wc -l | tr -d ' ') + echo "# setopts $sh_opt_count" + if [ -n "$sh_opts" ]; then + printf 'set -o %s\n' $sh_opts + fi +else + echo '# setopts 0' +fi +echo '' +if alias >/dev/null 2>&1; then + alias_count=$(alias | wc -l | tr -d ' ') + echo "# aliases $alias_count" + alias + echo '' +else + echo '# aliases 0' +fi +if export -p >/dev/null 2>&1; then + export_count=$(export -p | wc -l | tr -d ' ') + echo "# exports $export_count" + export -p +else + export_count=$(env | wc -l | tr -d ' ') + echo "# exports $export_count" + env | sort | while IFS='=' read -r key value; do + escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") + printf "export %s='%s'\n" "$key" "$escaped" + done +fi +"## +} + +fn powershell_snapshot_script() -> &'static str { + r##"$ErrorActionPreference = 'Stop' +Write-Output '# Snapshot file' +Write-Output '# Unset all aliases to avoid conflicts with functions' +Write-Output 'Remove-Item Alias:* -ErrorAction SilentlyContinue' +Write-Output '# Functions' +Get-ChildItem Function: | ForEach-Object { + "function {0} {{`n{1}`n}}" -f $_.Name, $_.Definition +} +Write-Output '' +$aliases = Get-Alias +Write-Output ("# aliases " + $aliases.Count) +$aliases | ForEach-Object { + "Set-Alias -Name {0} -Value {1}" -f $_.Name, $_.Definition +} +Write-Output '' +$envVars = Get-ChildItem Env: +Write-Output ("# exports " + $envVars.Count) +$envVars | ForEach-Object { + $escaped = $_.Value -replace "'", "''" + "`$env:{0}='{1}'" -f $_.Name, $escaped +} +"## +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; + #[cfg(target_os = "linux")] + use std::process::Command as StdCommand; + use std::sync::Arc; + use tempfile::tempdir; + + #[cfg(not(target_os = "windows"))] + fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!( + snapshot.contains("PATH"), + "snapshot should capture a PATH export" + ); + assert!(snapshot.contains("setopts ")); + } + + async fn get_snapshot(shell_type: ShellType) -> Result { + let dir = tempdir()?; + let path = dir.path().join("snapshot.sh"); + write_shell_snapshot(shell_type, &path).await?; + let content = fs::read_to_string(&path).await?; + Ok(content) + } + + #[test] + fn strip_snapshot_preamble_removes_leading_output() { + let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n"; + let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists"); + assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n"); + } + + #[test] + fn strip_snapshot_preamble_requires_marker() { + let result = strip_snapshot_preamble("missing header"); + assert!(result.is_err()); + } + + #[cfg(unix)] + #[test] + fn wrap_command_with_snapshot_wraps_bash_shell() { + let snapshot_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: snapshot_path.clone(), + })), + }; + let original_command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = shell.wrap_command_with_snapshot(&original_command); + + let mut expected = shell.derive_exec_args(". \"$0\" && exec \"$@\"", false); + expected.push(snapshot_path.to_string_lossy().to_string()); + expected.extend_from_slice(&original_command); + + assert_eq!(wrapped, expected); + } + + #[test] + fn wrap_command_with_snapshot_preserves_cmd_shell() { + let snapshot_path = PathBuf::from("C:\\snapshot.cmd"); + let shell = Shell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd"), + shell_snapshot: Some(Arc::new(ShellSnapshot { + path: snapshot_path, + })), + }; + let original_command = vec![ + "cmd".to_string(), + "/c".to_string(), + "echo hello".to_string(), + ]; + + let wrapped = shell.wrap_command_with_snapshot(&original_command); + + assert_eq!(wrapped, original_command); + } + + #[cfg(unix)] + #[tokio::test] + async fn try_new_creates_and_deletes_snapshot_file() -> Result<()> { + let dir = tempdir()?; + let shell = Shell { + shell_type: ShellType::Bash, + shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, + }; + + let snapshot = ShellSnapshot::try_new(dir.path(), &shell) + .await + .expect("snapshot should be created"); + let path = snapshot.path.clone(); + assert!(path.exists()); + + drop(snapshot); + + assert!(!path.exists()); + + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { + use std::process::Stdio; + use tokio::time::Duration as TokioDuration; + use tokio::time::Instant; + use tokio::time::sleep; + + let dir = tempdir()?; + let shell_path = dir.path().join("hanging-shell.sh"); + let pid_path = dir.path().join("pid"); + + let script = format!( + "#!/bin/sh\n\ + echo $$ > {}\n\ + sleep 30\n", + pid_path.display() + ); + fs::write(&shell_path, script).await?; + let mut permissions = std::fs::metadata(&shell_path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&shell_path, permissions)?; + + let shell = Shell { + shell_type: ShellType::Sh, + shell_path, + shell_snapshot: None, + }; + + let err = run_shell_script_with_timeout(&shell, "ignored", Duration::from_millis(500)) + .await + .expect_err("snapshot shell should time out"); + assert!( + err.to_string().contains("timed out"), + "expected timeout error, got {err:?}" + ); + + let pid = fs::read_to_string(&pid_path) + .await + .expect("snapshot shell writes its pid before timing out") + .trim() + .parse::()?; + + let deadline = Instant::now() + TokioDuration::from_secs(1); + loop { + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + if !kill_status.success() { + break; + } + if Instant::now() >= deadline { + panic!("timed out snapshot shell is still alive after grace period"); + } + sleep(TokioDuration::from_millis(50)).await; + } + + Ok(()) + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn macos_zsh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Zsh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_bash_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Bash).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linux_sh_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::Sh).await?; + assert_posix_snapshot_sections(&snapshot); + Ok(()) + } + + #[cfg(target_os = "windows")] + #[ignore] + #[tokio::test] + async fn windows_powershell_snapshot_includes_sections() -> Result<()> { + let snapshot = get_snapshot(ShellType::PowerShell).await?; + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + Ok(()) + } +} diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs new file mode 100644 index 00000000000..a143fce1f22 --- /dev/null +++ b/codex-rs/core/src/skills/injection.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use crate::skills::SkillLoadOutcome; +use crate::skills::SkillMetadata; +use crate::user_instructions::SkillInstructions; +use codex_protocol::models::ResponseItem; +use codex_protocol::user_input::UserInput; +use tokio::fs; + +#[derive(Debug, Default)] +pub(crate) struct SkillInjections { + pub(crate) items: Vec, + pub(crate) warnings: Vec, +} + +pub(crate) async fn build_skill_injections( + inputs: &[UserInput], + skills: Option<&SkillLoadOutcome>, +) -> SkillInjections { + if inputs.is_empty() { + return SkillInjections::default(); + } + + let Some(outcome) = skills else { + return SkillInjections::default(); + }; + + let mentioned_skills = collect_explicit_skill_mentions(inputs, &outcome.skills); + if mentioned_skills.is_empty() { + return SkillInjections::default(); + } + + let mut result = SkillInjections { + items: Vec::with_capacity(mentioned_skills.len()), + warnings: Vec::new(), + }; + + for skill in mentioned_skills { + match fs::read_to_string(&skill.path).await { + Ok(contents) => { + result.items.push(ResponseItem::from(SkillInstructions { + name: skill.name, + path: skill.path.to_string_lossy().into_owned(), + contents, + })); + } + Err(err) => { + let message = format!( + "Failed to load skill {} at {}: {err:#}", + skill.name, + skill.path.display() + ); + result.warnings.push(message); + } + } + } + + result +} + +fn collect_explicit_skill_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], +) -> Vec { + let mut selected: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for input in inputs { + if let UserInput::Skill { name, path } = input + && seen.insert(name.clone()) + && let Some(skill) = skills.iter().find(|s| s.name == *name && s.path == *path) + { + selected.push(skill.clone()); + } + } + + selected +} diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index a9ea156f021..c014af31477 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,4 +1,5 @@ use crate::config::Config; +use crate::git_info::resolve_root_git_project_for_trust; use crate::skills::model::SkillError; use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; @@ -20,6 +21,7 @@ struct SkillFrontmatter { const SKILLS_FILENAME: &str = "SKILL.md"; const SKILLS_DIR_NAME: &str = "skills"; +const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; const MAX_NAME_LEN: usize = 100; const MAX_DESCRIPTION_LEN: usize = 500; @@ -65,7 +67,17 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome { } fn skill_roots(config: &Config) -> Vec { - vec![config.codex_home.join(SKILLS_DIR_NAME)] + let mut roots = vec![config.codex_home.join(SKILLS_DIR_NAME)]; + + if let Some(repo_root) = resolve_root_git_project_for_trust(&config.cwd) { + roots.push( + repo_root + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + ); + } + + roots } fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { @@ -196,6 +208,9 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use pretty_assertions::assert_eq; + use std::path::Path; + use std::process::Command; use tempfile::TempDir; fn make_config(codex_home: &TempDir) -> Config { @@ -211,7 +226,11 @@ mod tests { } fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { - let skill_dir = codex_home.path().join(format!("skills/{dir}")); + write_skill_at(codex_home.path(), dir, name, description) + } + + fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf { + let skill_dir = root.join(format!("skills/{dir}")); fs::create_dir_all(&skill_dir).unwrap(); let indented_description = description.replace('\n', "\n "); let content = format!( @@ -288,4 +307,37 @@ mod tests { "expected length error" ); } + + #[test] + fn loads_skills_from_repo_root() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let repo_dir = tempfile::tempdir().expect("tempdir"); + + let status = Command::new("git") + .arg("init") + .current_dir(repo_dir.path()) + .status() + .expect("git init"); + assert!(status.success(), "git init failed"); + + let skills_root = repo_dir + .path() + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME); + write_skill_at(&skills_root, "repo", "repo-skill", "from repo"); + let mut cfg = make_config(&codex_home); + cfg.cwd = repo_dir.path().to_path_buf(); + let repo_root = normalize_path(&skills_root).unwrap_or_else(|_| skills_root.clone()); + + let outcome = load_skills(&cfg); + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!(outcome.skills.len(), 1); + let skill = &outcome.skills[0]; + assert_eq!(skill.name, "repo-skill"); + assert!(skill.path.starts_with(&repo_root)); + } } diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index ebb1490c99f..b2ab935ce53 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,7 +1,10 @@ +pub mod injection; pub mod loader; pub mod model; pub mod render; +pub(crate) use injection::SkillInjections; +pub(crate) use injection::build_skill_injections; pub use loader::load_skills; pub use model::SkillError; pub use model::SkillLoadOutcome; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a35720a9bf7..0270f3411c8 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,6 +4,7 @@ use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_models::models_manager::ModelsManager; +use crate::skills::SkillLoadOutcome; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -18,10 +19,11 @@ pub(crate) struct SessionServices { pub(crate) unified_exec_manager: UnifiedExecSessionManager, pub(crate) notifier: UserNotifier, pub(crate) rollout: Mutex>, - pub(crate) user_shell: crate::shell::Shell, + pub(crate) user_shell: Arc, pub(crate) show_raw_agent_reasoning: bool, pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, pub(crate) otel_event_manager: OtelEventManager, pub(crate) tool_approvals: Mutex, + pub(crate) skills: Option, } diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs index ef5d42a2819..7e84c330f6d 100644 --- a/codex-rs/core/src/tasks/ghost_snapshot.rs +++ b/codex-rs/core/src/tasks/ghost_snapshot.rs @@ -15,6 +15,8 @@ use codex_protocol::user_input::UserInput; use codex_utils_readiness::Readiness; use codex_utils_readiness::Token; use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use tracing::info; use tracing::warn; @@ -23,6 +25,8 @@ pub(crate) struct GhostSnapshotTask { token: Token, } +const SNAPSHOT_WARNING_THRESHOLD: Duration = Duration::from_secs(240); + #[async_trait] impl SessionTask for GhostSnapshotTask { fn kind(&self) -> TaskKind { @@ -38,7 +42,33 @@ impl SessionTask for GhostSnapshotTask { ) -> Option { tokio::task::spawn(async move { let token = self.token; - let ctx_for_task = Arc::clone(&ctx); + // Channel used to signal when the snapshot work has finished so the + // timeout warning task can exit early without sending a warning. + let (snapshot_done_tx, snapshot_done_rx) = oneshot::channel::<()>(); + let ctx_for_warning = ctx.clone(); + let cancellation_token_for_warning = cancellation_token.clone(); + let session_for_warning = session.clone(); + // Fire a generic warning if the snapshot is still running after + // three minutes; this helps users discover large untracked files + // that might need to be added to .gitignore. + tokio::task::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => { + session_for_warning.session + .send_event( + &ctx_for_warning, + EventMsg::Warning(WarningEvent { + message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string() + }), + ) + .await; + } + _ = snapshot_done_rx => {} + _ = cancellation_token_for_warning.cancelled() => {} + } + }); + + let ctx_for_task = ctx.clone(); let cancelled = tokio::select! { _ = cancellation_token.cancelled() => true, _ = async { @@ -109,6 +139,8 @@ impl SessionTask for GhostSnapshotTask { } => false, }; + let _ = snapshot_done_tx.send(()); + if cancelled { info!("ghost snapshot task cancelled"); } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 5c2e8d08b9a..da7f29d4ad6 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -92,6 +92,8 @@ async fn start_review_conversation( // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); + + sub_agent_config.model = Some(config.review_model.clone()); (run_codex_conversation_one_shot( sub_agent_config, session.auth_manager(), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index ca5243241a2..aec09514ca3 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -24,6 +24,7 @@ use crate::protocol::ExecCommandSource; use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; use crate::sandboxing::ExecEnv; +use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::user_shell_command::user_shell_command_record_item; @@ -100,7 +101,7 @@ impl SessionTask for UserShellCommandTask { // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), sandbox: SandboxType::None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 93bce604894..cdfc575cd9b 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -134,7 +134,6 @@ impl ToolEmitter { command: &[String], cwd: PathBuf, source: ExecCommandSource, - interaction_input: Option, process_id: Option, ) -> Self { let parsed_cmd = parse_command(command); @@ -142,7 +141,7 @@ impl ToolEmitter { command: command.to_vec(), cwd, source, - interaction_input, + interaction_input: None, // TODO(jif) drop this field in the protocol. parsed_cmd, process_id, } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 4a28619c760..5b8a04b388f 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -46,7 +46,7 @@ impl ToolHandler for ApplyPatchHandler { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { true } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index cd05d126bf8..9c306a186ee 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -10,7 +10,6 @@ use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; -use crate::sandboxing::SandboxPermissions; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -35,7 +34,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -49,15 +48,14 @@ impl ShellCommandHandler { turn_context: &TurnContext, ) -> ExecParams { let shell = session.user_shell(); - let use_login_shell = true; - let command = shell.derive_exec_args(¶ms.command, use_login_shell); + let command = shell.derive_exec_args(¶ms.command, params.login.unwrap_or(true)); ExecParams { command, cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -77,7 +75,7 @@ impl ToolHandler for ShellHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { match &invocation.payload { ToolPayload::Function { arguments } => { serde_json::from_str::(arguments) @@ -149,6 +147,20 @@ impl ToolHandler for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + let ToolPayload::Function { arguments } = &invocation.payload else { + return true; + }; + + serde_json::from_str::(arguments) + .map(|params| { + let shell = invocation.session.user_shell(); + let command = shell.derive_exec_args(¶ms.command, params.login.unwrap_or(true)); + !is_known_safe_command(&command) + }) + .unwrap_or(true) + } + async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, @@ -193,7 +205,9 @@ impl ShellHandler { freeform: bool, ) -> Result { // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params.with_escalated_permissions.unwrap_or(false) + if exec_params + .sandbox_permissions + .requires_escalated_permissions() && !matches!( turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -238,7 +252,7 @@ impl ShellHandler { &exec_params.command, turn.approval_policy, &turn.sandbox_policy, - SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), + exec_params.sandbox_permissions, ) .await; @@ -247,7 +261,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), - with_escalated_permissions: exec_params.with_escalated_permissions, + sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, }; @@ -276,9 +290,16 @@ impl ShellHandler { mod tests { use std::path::PathBuf; + use codex_protocol::models::ShellCommandToolCallParams; + use pretty_assertions::assert_eq; + + use crate::codex::make_session_and_context; + use crate::exec_env::create_env; use crate::is_safe_command::is_known_safe_command; + use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; + use crate::tools::handlers::ShellCommandHandler; /// The logic for is_known_safe_command() has heuristics for known shells, /// so we must ensure the commands generated by [ShellCommandHandler] can be @@ -288,18 +309,21 @@ mod tests { let bash_shell = Shell { shell_type: ShellType::Bash, shell_path: PathBuf::from("/bin/bash"), + shell_snapshot: None, }; assert_safe(&bash_shell, "ls -la"); let zsh_shell = Shell { shell_type: ShellType::Zsh, shell_path: PathBuf::from("/bin/zsh"), + shell_snapshot: None, }; assert_safe(&zsh_shell, "ls -la"); let powershell = Shell { shell_type: ShellType::PowerShell, shell_path: PathBuf::from("pwsh.exe"), + shell_snapshot: None, }; assert_safe(&powershell, "ls -Name"); } @@ -312,4 +336,40 @@ mod tests { &shell.derive_exec_args(command, /* use_login_shell */ false) )); } + + #[test] + fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_context() { + let (session, turn_context) = make_session_and_context(); + + let command = "echo hello".to_string(); + let workdir = Some("subdir".to_string()); + let login = None; + let timeout_ms = Some(1234); + let sandbox_permissions = SandboxPermissions::RequireEscalated; + let justification = Some("because tests".to_string()); + + let expected_command = session.user_shell().derive_exec_args(&command, true); + let expected_cwd = turn_context.resolve_path(workdir.clone()); + let expected_env = create_env(&turn_context.shell_environment_policy); + + let params = ShellCommandToolCallParams { + command, + workdir, + login, + timeout_ms, + sandbox_permissions: Some(sandbox_permissions), + justification: justification.clone(), + }; + + let exec_params = ShellCommandHandler::to_exec_params(params, &session, &turn_context); + + // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. + assert_eq!(exec_params.command, expected_command); + assert_eq!(exec_params.cwd, expected_cwd); + assert_eq!(exec_params.env, expected_env); + assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); + assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); + assert_eq!(exec_params.justification, justification); + assert_eq!(exec_params.arg0, None); + } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index f2500a413ba..0d3a11da106 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,12 +1,10 @@ -use std::path::PathBuf; - use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::ExecOutputStream; -use crate::shell::default_user_shell; +use crate::protocol::TerminalInteractionEvent; +use crate::sandboxing::SandboxPermissions; +use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -24,6 +22,8 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use serde::Deserialize; +use std::path::PathBuf; +use std::sync::Arc; pub struct UnifiedExecHandler; @@ -34,14 +34,14 @@ struct ExecCommandArgs { workdir: Option, #[serde(default)] shell: Option, - #[serde(default = "default_login")] - login: bool, + #[serde(default)] + login: Option, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] max_output_tokens: Option, #[serde(default)] - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, #[serde(default)] justification: Option, } @@ -66,10 +66,6 @@ fn default_write_stdin_yield_time_ms() -> u64 { 250 } -fn default_login() -> bool { - true -} - #[async_trait] impl ToolHandler for UnifiedExecHandler { fn kind(&self) -> ToolKind { @@ -83,7 +79,7 @@ impl ToolHandler for UnifiedExecHandler { ) } - fn is_mutating(&self, invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, invocation: &ToolInvocation) -> bool { let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) = &invocation.payload else { @@ -93,7 +89,7 @@ impl ToolHandler for UnifiedExecHandler { let Ok(params) = serde_json::from_str::(arguments) else { return true; }; - let command = get_command(¶ms); + let command = get_command(¶ms, invocation.session.user_shell()); !is_known_safe_command(&command) } @@ -130,17 +126,18 @@ impl ToolHandler for UnifiedExecHandler { })?; let process_id = manager.allocate_process_id().await; - let command = get_command(&args); + let command_for_intercept = get_command(&args, session.user_shell()); let ExecCommandArgs { workdir, + login, yield_time_ms, max_output_tokens, - with_escalated_permissions, + sandbox_permissions, justification, .. } = args; - if with_escalated_permissions.unwrap_or(false) + if sandbox_permissions.requires_escalated_permissions() && !matches!( context.turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -159,7 +156,7 @@ impl ToolHandler for UnifiedExecHandler { let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); if let Some(output) = intercept_apply_patch( - &command, + &command_for_intercept, &cwd, Some(yield_time_ms), context.session.as_ref(), @@ -180,11 +177,18 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); + let command = if login.is_none() { + context + .session + .user_shell() + .wrap_command_with_snapshot(&command_for_intercept) + } else { + command_for_intercept + }; let emitter = ToolEmitter::unified_exec( &command, cwd.clone(), ExecCommandSource::UnifiedExecStartup, - None, Some(process_id.clone()), ); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -197,7 +201,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, - with_escalated_permissions, + sandbox_permissions, justification, }, &context, @@ -213,9 +217,8 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse write_stdin arguments: {err:?}" )) })?; - manager + let response = manager .write_stdin(WriteStdinRequest { - call_id: &call_id, process_id: &args.session_id.to_string(), input: &args.chars, yield_time_ms: args.yield_time_ms, @@ -224,7 +227,18 @@ impl ToolHandler for UnifiedExecHandler { .await .map_err(|err| { FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) - })? + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + + response } other => { return Err(FunctionCallError::RespondToModel(format!( @@ -233,18 +247,6 @@ impl ToolHandler for UnifiedExecHandler { } }; - // Emit a delta event with the chunk of output we just produced, if any. - if !response.output.is_empty() { - let delta = ExecCommandOutputDeltaEvent { - call_id: response.event_call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: response.output.as_bytes().to_vec(), - }; - session - .send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta)) - .await; - } - let content = format_response(&response); Ok(ToolOutput::Function { @@ -255,14 +257,15 @@ impl ToolHandler for UnifiedExecHandler { } } -fn get_command(args: &ExecCommandArgs) -> Vec { - let shell = if let Some(shell_str) = &args.shell { - get_shell_by_model_provided_path(&PathBuf::from(shell_str)) - } else { - default_user_shell() - }; +fn get_command(args: &ExecCommandArgs, session_shell: Arc) -> Vec { + if let Some(shell_str) = &args.shell { + let mut shell = get_shell_by_model_provided_path(&PathBuf::from(shell_str)); + shell.shell_snapshot = None; + return shell.derive_exec_args(&args.cmd, args.login.unwrap_or(true)); + } - shell.derive_exec_args(&args.cmd, args.login) + let use_login_shell = args.login.unwrap_or(session_shell.shell_snapshot.is_none()); + session_shell.derive_exec_args(&args.cmd, use_login_shell) } fn format_response(response: &UnifiedExecResponse) -> String { @@ -297,6 +300,8 @@ fn format_response(response: &UnifiedExecResponse) -> String { #[cfg(test)] mod tests { use super::*; + use crate::shell::default_user_shell; + use std::sync::Arc; #[test] fn test_get_command_uses_default_shell_when_unspecified() { @@ -307,7 +312,7 @@ mod tests { assert!(args.shell.is_none()); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -322,7 +327,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -336,7 +341,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("powershell")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } @@ -350,7 +355,7 @@ mod tests { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command(&args); + let command = get_command(&args, Arc::new(default_user_shell())); assert_eq!(command[2], "echo hello"); } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4c34658fcd5..003c727610b 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -7,12 +7,10 @@ retry without sandbox on denial (no re‑approval thanks to caching). */ use crate::error::CodexErr; use crate::error::SandboxErr; -use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::ToolCtx; @@ -43,7 +41,6 @@ impl ToolOrchestrator { ) -> Result where T: ToolRuntime, - Rq: ProvidesSandboxRetryData, { let otel = turn_ctx.client.get_otel_event_manager(); let otel_tn = &tool_ctx.tool_name; @@ -65,26 +62,11 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ExecApprovalRequirement::NeedsApproval { reason, .. } => { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - None, - ) - .await; - } - let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -141,33 +123,12 @@ impl ToolOrchestrator { // Ask for approval before retrying without sandbox. if !tool.should_bypass_approval(approval_policy, already_approved) { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - let err = SandboxErr::Denied { - output: output.clone(), - }; - let friendly = get_error_message_ui(&CodexErr::Sandbox(err)); - let failure_summary = format!("failed in sandbox: {friendly}"); - - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - Some(failure_summary.as_str()), - ) - .await; - } - let reason_msg = build_denial_reason_from_output(output.as_ref()); let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: Some(reason_msg), - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f35ff063155..9b33e84b76b 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -30,7 +30,7 @@ pub trait ToolHandler: Send + Sync { ) } - fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { false } @@ -110,7 +110,7 @@ impl ToolRegistry { let output_cell = &output_cell; let invocation = invocation; async move { - if handler.is_mutating(&invocation) { + if handler.is_mutating(&invocation).await { tracing::trace!("waiting for tool gate"); invocation.turn.tool_call_gate.wait_ready().await; tracing::trace!("tool gate released"); diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7152d3c1ec9..b6675bcd5d1 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -5,6 +5,7 @@ use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::sandboxing::SandboxPermissions; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -114,7 +115,7 @@ impl ToolRouter { command: exec.command, workdir: exec.working_directory, timeout_ms: exec.timeout_ms, - with_escalated_permissions: None, + sandbox_permissions: Some(SandboxPermissions::UseDefault), justification: None, }; Ok(Some(ToolCall { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 7ef8d33767a..26d04f578c5 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -7,12 +7,11 @@ use crate::CODEX_APPLY_PATCH_ARG1; use crate::exec::ExecToolCallOutput; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -34,12 +33,6 @@ pub struct ApplyPatchRequest { pub codex_exe: Option, } -impl ProvidesSandboxRetryData for ApplyPatchRequest { - fn sandbox_retry_data(&self) -> Option { - None - } -} - #[derive(Default)] pub struct ApplyPatchRuntime; @@ -70,7 +63,7 @@ impl ApplyPatchRuntime { expiration: req.timeout_ms.into(), // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }) } @@ -114,7 +107,6 @@ impl Approvable for ApplyPatchRuntime { let call_id = ctx.call_id.to_string(); let cwd = req.cwd.clone(); let retry_reason = ctx.retry_reason.clone(); - let risk = ctx.risk.clone(); let user_explicitly_approved = req.user_explicitly_approved; Box::pin(async move { with_cached_approval(&session.services, key, move || async move { @@ -126,7 +118,6 @@ impl Approvable for ApplyPatchRuntime { vec!["apply_patch".to_string()], cwd, Some(reason), - risk, None, ) .await diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 437f4af428b..2431b3c97d3 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -6,6 +6,7 @@ small and focused and reuses the orchestrator for approvals + sandbox + retry. */ use crate::exec::ExecExpiration; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ToolError; use std::collections::HashMap; use std::path::Path; @@ -21,7 +22,7 @@ pub(crate) fn build_command_spec( cwd: &Path, env: &HashMap, expiration: ExecExpiration, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, ) -> Result { let (program, args) = command @@ -33,7 +34,7 @@ pub(crate) fn build_command_spec( cwd: cwd.to_path_buf(), env: env.clone(), expiration, - with_escalated_permissions, + sandbox_permissions, justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 2af095ee92b..078be68e890 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -5,15 +5,14 @@ Executes shell requests under the orchestrator: asks for approval when needed, builds a CommandSpec, and runs it under the current SandboxAttempt. */ use crate::exec::ExecToolCallOutput; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -30,20 +29,11 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for ShellRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(Default)] pub struct ShellRuntime; @@ -51,7 +41,7 @@ pub struct ShellRuntime; pub(crate) struct ApprovalKey { command: Vec, cwd: PathBuf, - escalated: bool, + sandbox_permissions: SandboxPermissions, } impl ShellRuntime { @@ -84,7 +74,7 @@ impl Approvable for ShellRuntime { ApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -100,7 +90,6 @@ impl Approvable for ShellRuntime { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); @@ -113,7 +102,6 @@ impl Approvable for ShellRuntime { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -129,11 +117,12 @@ impl Approvable for ShellRuntime { } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { - bypass_sandbox: true + bypass_sandbox: true, + .. } ) { @@ -156,7 +145,7 @@ impl ToolRuntime for ShellRuntime { &req.cwd, &req.env, req.timeout_ms.into(), - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), )?; let env = attempt diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 4c1cbb83eca..3d35987c7e2 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,14 +7,13 @@ the session manager to spawn PTYs once an ExecEnv is prepared. use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecExpiration; +use crate::sandboxing::SandboxPermissions; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -34,25 +33,16 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for UnifiedExecRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, pub cwd: PathBuf, - pub escalated: bool, + pub sandbox_permissions: SandboxPermissions, } pub struct UnifiedExecRuntime<'a> { @@ -64,7 +54,7 @@ impl UnifiedExecRequest { command: Vec, cwd: PathBuf, env: HashMap, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, exec_approval_requirement: ExecApprovalRequirement, ) -> Self { @@ -72,7 +62,7 @@ impl UnifiedExecRequest { command, cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, } @@ -102,7 +92,7 @@ impl Approvable for UnifiedExecRuntime<'_> { UnifiedExecApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -121,7 +111,6 @@ impl Approvable for UnifiedExecRuntime<'_> { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); Box::pin(async move { with_cached_approval(&session.services, key, || async move { session @@ -131,7 +120,6 @@ impl Approvable for UnifiedExecRuntime<'_> { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -150,11 +138,12 @@ impl Approvable for UnifiedExecRuntime<'_> { } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { - bypass_sandbox: true + bypass_sandbox: true, + .. } ) { @@ -177,7 +166,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.cwd, &req.env, ExecExpiration::DefaultTimeout, - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 94c81043ccf..96bc633c584 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -7,7 +7,6 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -20,7 +19,6 @@ use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::path::Path; -use std::path::PathBuf; use futures::Future; use futures::future::BoxFuture; @@ -84,7 +82,6 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, - pub risk: Option, } // Specifies what tool orchestrator should do with a given tool call. @@ -95,6 +92,9 @@ pub(crate) enum ExecApprovalRequirement { /// The first attempt should skip sandboxing (e.g., when explicitly /// greenlit by policy). bypass_sandbox: bool, + /// Proposed execpolicy amendment to skip future approvals for similar commands + /// Only applies if the command fails to run in sandbox and codex prompts the user to run outside the sandbox. + proposed_execpolicy_amendment: Option, }, /// Approval required for this tool call. NeedsApproval { @@ -114,6 +114,10 @@ impl ExecApprovalRequirement { proposed_execpolicy_amendment: Some(prefix), .. } => Some(prefix), + Self::Skip { + proposed_execpolicy_amendment: Some(prefix), + .. + } => Some(prefix), _ => None, } } @@ -140,6 +144,7 @@ pub(crate) fn default_exec_approval_requirement( } else { ExecApprovalRequirement::Skip { bypass_sandbox: false, + proposed_execpolicy_amendment: None, } } } @@ -211,17 +216,6 @@ pub(crate) struct ToolCtx<'a> { pub tool_name: String, } -/// Captures the command metadata needed to re-run a tool request without sandboxing. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct SandboxRetryData { - pub command: Vec, - pub cwd: PathBuf, -} - -pub(crate) trait ProvidesSandboxRetryData { - fn sandbox_retry_data(&self) -> Option; -} - #[derive(Debug)] pub(crate) enum ToolError { Rejected(String), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a36f54a6be0..0b74b9e10a8 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -174,10 +174,10 @@ fn create_exec_command_tool() -> ToolSpec { }, ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { + "sandbox_permissions".to_string(), + JsonSchema::String { description: Some( - "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions" + "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." .to_string(), ), }, @@ -186,7 +186,7 @@ fn create_exec_command_tool() -> ToolSpec { "justification".to_string(), JsonSchema::String { description: Some( - "Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command." + "Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command." .to_string(), ), }, @@ -274,15 +274,15 @@ fn create_shell_tool() -> ToolSpec { ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { - description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".to_string()), }, ); properties.insert( "justification".to_string(), JsonSchema::String { - description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); @@ -331,6 +331,15 @@ fn create_shell_command_tool() -> ToolSpec { description: Some("The working directory to execute the command in".to_string()), }, ); + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the shell with login shell semantics. Defaults to true." + .to_string(), + ), + }, + ); properties.insert( "timeout_ms".to_string(), JsonSchema::Number { @@ -338,15 +347,15 @@ fn create_shell_command_tool() -> ToolSpec { }, ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { - description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".to_string()), }, ); properties.insert( "justification".to_string(), JsonSchema::String { - description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); @@ -795,10 +804,16 @@ pub(crate) fn create_tools_json_for_chat_completions_api( } if let Some(map) = tool.as_object_mut() { + let name = map + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); // Remove "type" field as it is not needed in chat completions. map.remove("type"); Some(json!({ "type": "function", + "name": name, "function": map, })) } else { @@ -2074,4 +2089,58 @@ Examples of valid command strings: }) ); } + + #[test] + fn chat_tools_include_top_level_name() { + let mut properties = BTreeMap::new(); + properties.insert("foo".to_string(), JsonSchema::String { description: None }); + let tools = vec![ToolSpec::Function(ResponsesApiTool { + name: "demo".to_string(), + description: "A demo tool".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: None, + }, + })]; + + let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); + assert_eq!( + responses_json, + vec![json!({ + "type": "function", + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + })] + ); + + let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap(); + + assert_eq!( + tools_json, + vec![json!({ + "type": "function", + "name": "demo", + "function": { + "name": "demo", + "description": "A demo tool", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "foo": { "type": "string" } + }, + }, + } + })] + ); + } } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs new file mode 100644 index 00000000000..19d91dbccf2 --- /dev/null +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -0,0 +1,231 @@ +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::Sleep; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecToolCallOutput; +use crate::exec::StreamOutput; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandOutputDeltaEvent; +use crate::protocol::ExecCommandSource; +use crate::protocol::ExecOutputStream; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventStage; + +use super::CommandTranscript; +use super::UnifiedExecContext; +use super::session::UnifiedExecSession; + +pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); + +/// Spawn a background task that continuously reads from the PTY, appends to the +/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 +/// boundaries. +pub(crate) fn start_streaming_output( + session: &UnifiedExecSession, + context: &UnifiedExecContext, + transcript: Arc>, +) { + let mut receiver = session.output_receiver(); + let output_drained = session.output_drained_notify(); + let exit_token = session.cancellation_token(); + + let session_ref = Arc::clone(&context.session); + let turn_ref = Arc::clone(&context.turn); + let call_id = context.call_id.clone(); + + tokio::spawn(async move { + use tokio::sync::broadcast::error::RecvError; + + let mut pending = Vec::::new(); + + let mut grace_sleep: Option>> = None; + + loop { + tokio::select! { + _ = exit_token.cancelled(), if grace_sleep.is_none() => { + let deadline = Instant::now() + TRAILING_OUTPUT_GRACE; + grace_sleep.replace(Box::pin(tokio::time::sleep_until(deadline))); + } + + _ = async { + if let Some(sleep) = grace_sleep.as_mut() { + sleep.as_mut().await; + } + }, if grace_sleep.is_some() => { + output_drained.notify_one(); + break; + } + + received = receiver.recv() => { + let chunk = match received { + Ok(chunk) => chunk, + Err(RecvError::Lagged(_)) => { + continue; + }, + Err(RecvError::Closed) => { + output_drained.notify_one(); + break; + } + }; + + process_chunk( + &mut pending, + &transcript, + &call_id, + &session_ref, + &turn_ref, + chunk, + ).await; + } + } + } + }); +} + +/// Spawn a background watcher that waits for the PTY to exit and then emits a +/// single ExecCommandEnd event with the aggregated transcript. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_exit_watcher( + session: Arc, + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: String, + transcript: Arc>, + started_at: Instant, +) { + let exit_token = session.cancellation_token(); + let output_drained = session.output_drained_notify(); + + tokio::spawn(async move { + exit_token.cancelled().await; + output_drained.notified().await; + + let exit_code = session.exit_code().unwrap_or(-1); + let duration = Instant::now().saturating_duration_since(started_at); + emit_exec_end_for_unified_exec( + session_ref, + turn_ref, + call_id, + command, + cwd, + Some(process_id), + transcript, + String::new(), + exit_code, + duration, + ) + .await; + }); +} + +async fn process_chunk( + pending: &mut Vec, + transcript: &Arc>, + call_id: &str, + session_ref: &Arc, + turn_ref: &Arc, + chunk: Vec, +) { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.to_string(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } +} + +/// Emit an ExecCommandEnd event for a unified exec session, using the transcript +/// as the primary source of aggregated_output and falling back to the provided +/// text when the transcript is empty. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn emit_exec_end_for_unified_exec( + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: Option, + transcript: Arc>, + fallback_output: String, + exit_code: i32, + duration: Duration, +) { + let aggregated_output = resolve_aggregated_output(&transcript, fallback_output).await; + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new(session_ref.as_ref(), turn_ref.as_ref(), &call_id, None); + let emitter = ToolEmitter::unified_exec( + &command, + cwd, + ExecCommandSource::UnifiedExecStartup, + process_id, + ); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; +} + +fn split_valid_utf8_prefix(buffer: &mut Vec) -> Option> { + if buffer.is_empty() { + return None; + } + + let len = buffer.len(); + let mut split = len; + while split > 0 { + if std::str::from_utf8(&buffer[..split]).is_ok() { + let prefix = buffer[..split].to_vec(); + buffer.drain(..split); + return Some(prefix); + } + + if len - split > 4 { + break; + } + split -= 1; + } + + // If no valid UTF-8 prefix was found, emit the first byte so the stream + // keeps making progress and the transcript reflects all bytes. + let byte = buffer.drain(..1).collect(); + Some(byte) +} + +async fn resolve_aggregated_output( + transcript: &Arc>, + fallback: String, +) -> String { + let guard = transcript.lock().await; + if guard.data.is_empty() { + return fallback; + } + + String::from_utf8_lossy(&guard.data).to_string() +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 34b62df342c..814001f41fe 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,7 +33,9 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +use crate::sandboxing::SandboxPermissions; +mod async_watcher; mod errors; mod session; mod session_manager; @@ -51,6 +53,24 @@ pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64; // Send a warning message to the models when it reaches this number of sessions. pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60; +#[derive(Debug, Default)] +pub(crate) struct CommandTranscript { + pub data: Vec, +} + +impl CommandTranscript { + pub fn append(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES { + let excess = self + .data + .len() + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + self.data.drain(..excess); + } + } +} + pub(crate) struct UnifiedExecContext { pub session: Arc, pub turn: Arc, @@ -74,13 +94,12 @@ pub(crate) struct ExecCommandRequest { pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } #[derive(Debug)] pub(crate) struct WriteStdinRequest<'a> { - pub call_id: &'a str, pub process_id: &'a str, pub input: &'a str, pub yield_time_ms: u64, @@ -93,18 +112,14 @@ pub(crate) struct UnifiedExecResponse { pub chunk_id: String, pub wall_time: Duration, pub output: String, + /// Raw bytes returned for this unified exec call before any truncation. + pub raw_output: Vec, pub process_id: Option, pub exit_code: Option, pub original_token_count: Option, pub session_command: Option>, } -#[derive(Default)] -pub(crate) struct UnifiedExecSessionManager { - session_store: Mutex, -} - -// Required for mutex sharing. #[derive(Default)] pub(crate) struct SessionStore { sessions: HashMap, @@ -116,22 +131,27 @@ impl SessionStore { self.reserved_sessions_id.remove(session_id); self.sessions.remove(session_id) } +} - pub(crate) fn clear(&mut self) { - self.reserved_sessions_id.clear(); - self.sessions.clear(); +pub(crate) struct UnifiedExecSessionManager { + session_store: Mutex, +} + +impl Default for UnifiedExecSessionManager { + fn default() -> Self { + Self { + session_store: Mutex::new(SessionStore::default()), + } } } struct SessionEntry { - session: UnifiedExecSession, + session: Arc, session_ref: Arc, turn_ref: Arc, call_id: String, process_id: String, command: Vec, - cwd: PathBuf, - started_at: tokio::time::Instant, last_used: tokio::time::Instant, } @@ -198,7 +218,7 @@ mod tests { yield_time_ms, max_output_tokens: None, workdir: None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }, &context, @@ -216,7 +236,6 @@ mod tests { .services .unified_exec_manager .write_stdin(WriteStdinRequest { - call_id: "write-stdin", process_id, input, yield_time_ms, diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 02465538ec3..4973a1a6417 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -79,6 +79,7 @@ pub(crate) struct UnifiedExecSession { output_buffer: OutputBuffer, output_notify: Arc, cancellation_token: CancellationToken, + output_drained: Arc, output_task: JoinHandle<()>, sandbox_type: SandboxType, } @@ -92,10 +93,10 @@ impl UnifiedExecSession { let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); let output_notify = Arc::new(Notify::new()); let cancellation_token = CancellationToken::new(); + let output_drained = Arc::new(Notify::new()); let mut receiver = initial_output_rx; let buffer_clone = Arc::clone(&output_buffer); let notify_clone = Arc::clone(&output_notify); - let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { match receiver.recv().await { @@ -106,11 +107,8 @@ impl UnifiedExecSession { notify_clone.notify_waiters(); } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } - } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; } }); @@ -119,6 +117,7 @@ impl UnifiedExecSession { output_buffer, output_notify, cancellation_token, + output_drained, output_task, sandbox_type, } @@ -136,6 +135,18 @@ impl UnifiedExecSession { } } + pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver> { + self.session.output_receiver() + } + + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } + + pub(super) fn output_drained_notify(&self) -> Arc { + Arc::clone(&self.output_drained) + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } @@ -144,6 +155,12 @@ impl UnifiedExecSession { self.session.exit_code() } + pub(super) fn terminate(&self) { + self.session.terminate(); + self.cancellation_token.cancel(); + self.output_task.abort(); + } + async fn snapshot_output(&self) -> Vec> { let guard = self.output_buffer.lock().await; guard.snapshot() @@ -246,6 +263,6 @@ impl UnifiedExecSession { impl Drop for UnifiedExecSession { fn drop(&mut self) { - self.output_task.abort(); + self.terminate(); } } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 88d65ca1424..4b24c574ac2 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -13,19 +13,12 @@ use tokio_util::sync::CancellationToken; use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; -use crate::exec::ExecToolCallOutput; -use crate::exec::StreamOutput; use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandSource; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventFailure; -use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -34,6 +27,7 @@ use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; +use super::CommandTranscript; use super::ExecCommandRequest; use super::MAX_UNIFIED_EXEC_SESSIONS; use super::SessionEntry; @@ -44,6 +38,9 @@ use super::UnifiedExecResponse; use super::UnifiedExecSessionManager; use super::WARNING_UNIFIED_EXEC_SESSIONS; use super::WriteStdinRequest; +use super::async_watcher::emit_exec_end_for_unified_exec; +use super::async_watcher::spawn_exit_watcher; +use super::async_watcher::start_streaming_output; use super::clamp_yield_time; use super::generate_chunk_id; use super::resolve_max_tokens; @@ -77,7 +74,6 @@ struct PreparedSessionHandles { session_ref: Arc, turn_ref: Arc, command: Vec, - cwd: PathBuf, process_id: String, } @@ -130,24 +126,30 @@ impl UnifiedExecSessionManager { .open_session_with_sandbox( &request.command, cwd.clone(), - request.with_escalated_permissions, + request.sandbox_permissions, request.justification, context, ) .await; let session = match session { - Ok(session) => session, + Ok(session) => Arc::new(session), Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); } }; + let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default())); + start_streaming_output(&session, context, Arc::clone(&transcript)); + let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); let start = Instant::now(); + // For the initial exec_command call, we both stream output to events + // (via start_streaming_output above) and collect a snapshot here for + // the tool response body. let OutputHandles { output_buffer, output_notify, @@ -165,36 +167,44 @@ impl UnifiedExecSessionManager { let text = String::from_utf8_lossy(&collected).to_string(); let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); - let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let has_exited = session.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); if has_exited { + // Short‑lived command: emit ExecCommandEnd immediately using the + // same helper as the background watcher, so all end events share + // one implementation. self.release_process_id(&request.process_id).await; let exit = exit_code.unwrap_or(-1); - Self::emit_exec_end_from_context( - context, - &request.command, + emit_exec_end_for_unified_exec( + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + request.command.clone(), cwd, + Some(process_id), + Arc::clone(&transcript), output.clone(), exit, wall_time, - // We always emit the process ID in order to keep consistency between the Begin - // event and the End event. - Some(process_id), ) .await; session.check_for_sandbox_denial_with_text(&text).await?; } else { - // Only store session if not exited. + // Long‑lived command: persist the session so write_stdin can reuse + // it, and register a background watcher that will emit + // ExecCommandEnd when the PTY eventually exits (even if no further + // tool calls are made). self.store_session( - session, + Arc::clone(&session), context, &request.command, cwd.clone(), start, process_id, + Arc::clone(&transcript), ) .await; @@ -207,6 +217,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id: if has_exited { None } else { @@ -234,41 +245,14 @@ impl UnifiedExecSessionManager { session_ref, turn_ref, command: session_command, - cwd: session_cwd, process_id, + .. } = self.prepare_session_handles(process_id.as_str()).await?; - let interaction_emitter = ToolEmitter::unified_exec( - &session_command, - session_cwd.clone(), - ExecCommandSource::UnifiedExecInteraction, - (!request.input.is_empty()).then(|| request.input.to_string()), - Some(process_id.clone()), - ); - let make_event_ctx = || { - ToolEventCtx::new( - session_ref.as_ref(), - turn_ref.as_ref(), - request.call_id, - None, - ) - }; - interaction_emitter - .emit(make_event_ctx(), ToolEventStage::Begin) - .await; - if !request.input.is_empty() { - if let Err(err) = Self::send_input(&writer_tx, request.input.as_bytes()).await { - interaction_emitter - .emit( - make_event_ctx(), - ToolEventStage::Failure(ToolEventFailure::Message(format!( - "write_stdin failed: {err:?}" - ))), - ) - .await; - return Err(err); - } + Self::send_input(&writer_tx, request.input.as_bytes()).await?; + // Give the remote process a brief window to react so that we are + // more likely to capture its output in the poll below. tokio::time::sleep(Duration::from_millis(100)).await; } @@ -290,16 +274,20 @@ impl UnifiedExecSessionManager { let original_token_count = approx_token_count(&text); let chunk_id = generate_chunk_id(); + // After polling, refresh_session_state tells us whether the PTY is + // still alive or has exited and been removed from the store; we thread + // that through so the handler can tag TerminalInteraction with an + // appropriate process_id and exit_code. let status = self.refresh_session_state(process_id.as_str()).await; - let (process_id, exit_code, completion_entry, event_call_id) = match status { + let (process_id, exit_code, event_call_id) = match status { SessionStatus::Alive { exit_code, call_id, process_id, - } => (Some(process_id), exit_code, None, call_id), + } => (Some(process_id), exit_code, call_id), SessionStatus::Exited { exit_code, entry } => { let call_id = entry.call_id.clone(); - (None, exit_code, Some(*entry), call_id) + (None, exit_code, call_id) } SessionStatus::Unknown => { return Err(UnifiedExecError::UnknownSessionId { @@ -313,37 +301,17 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id, exit_code, original_token_count: Some(original_token_count), session_command: Some(session_command.clone()), }; - let interaction_output = ExecToolCallOutput { - exit_code: response.exit_code.unwrap_or(0), - stdout: StreamOutput::new(response.output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(response.output.clone()), - duration: response.wall_time, - timed_out: false, - }; - interaction_emitter - .emit( - make_event_ctx(), - ToolEventStage::Success(interaction_output), - ) - .await; - if response.process_id.is_some() { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } - if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) { - let total_duration = Instant::now().saturating_duration_since(entry.started_at); - Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration) - .await; - } - Ok(response) } @@ -400,7 +368,6 @@ impl UnifiedExecSessionManager { session_ref: Arc::clone(&entry.session_ref), turn_ref: Arc::clone(&entry.turn_ref), command: entry.command.clone(), - cwd: entry.cwd.clone(), process_id: entry.process_id.clone(), }) } @@ -418,28 +385,27 @@ impl UnifiedExecSessionManager { #[allow(clippy::too_many_arguments)] async fn store_session( &self, - session: UnifiedExecSession, + session: Arc, context: &UnifiedExecContext, command: &[String], cwd: PathBuf, started_at: Instant, process_id: String, + transcript: Arc>, ) { let entry = SessionEntry { - session, + session: Arc::clone(&session), session_ref: Arc::clone(&context.session), turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), - cwd, - started_at, last_used: started_at, }; let number_sessions = { let mut store = self.session_store.lock().await; Self::prune_sessions_if_needed(&mut store); - store.sessions.insert(process_id, entry); + store.sessions.insert(process_id.clone(), entry); store.sessions.len() }; @@ -452,73 +418,18 @@ impl UnifiedExecSessionManager { ) .await; }; - } - async fn emit_exec_end_from_entry( - entry: SessionEntry, - aggregated_output: String, - exit_code: i32, - duration: Duration, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - entry.session_ref.as_ref(), - entry.turn_ref.as_ref(), - &entry.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - &entry.command, - entry.cwd, - ExecCommandSource::UnifiedExecStartup, - None, - Some(entry.process_id.clone()), - ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; - } - - async fn emit_exec_end_from_context( - context: &UnifiedExecContext, - command: &[String], - cwd: PathBuf, - aggregated_output: String, - exit_code: i32, - duration: Duration, - process_id: Option, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - context.session.as_ref(), - context.turn.as_ref(), - &context.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - command, + spawn_exit_watcher( + Arc::clone(&session), + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + command.to_vec(), cwd, - ExecCommandSource::UnifiedExecStartup, - None, process_id, + transcript, + started_at, ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; } async fn emit_waiting_status( @@ -565,7 +476,7 @@ impl UnifiedExecSessionManager { &self, command: &[String], cwd: PathBuf, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, context: &UnifiedExecContext, ) -> Result { @@ -579,14 +490,14 @@ impl UnifiedExecSessionManager { command, context.turn.approval_policy, &context.turn.sandbox_policy, - SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), + sandbox_permissions, ) .await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, ); @@ -614,7 +525,7 @@ impl UnifiedExecSessionManager { cancellation_token: &CancellationToken, deadline: Instant, ) -> Vec { - const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25); + const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(50); let mut collected: Vec = Vec::with_capacity(4096); let mut exit_signal_received = cancellation_token.is_cancelled(); @@ -681,7 +592,9 @@ impl UnifiedExecSessionManager { .collect(); if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) { - store.remove(&session_id); + if let Some(entry) = store.remove(&session_id) { + entry.session.terminate(); + } return true; } @@ -718,8 +631,17 @@ impl UnifiedExecSessionManager { } pub(crate) async fn terminate_all_sessions(&self) { - let mut sessions = self.session_store.lock().await; - sessions.clear(); + let entries: Vec = { + let mut sessions = self.session_store.lock().await; + let entries: Vec = + sessions.sessions.drain().map(|(_, entry)| entry).collect(); + sessions.reserved_sessions_id.clear(); + entries + }; + + for entry in entries { + entry.session.terminate(); + } } } diff --git a/codex-rs/core/src/user_instructions.rs b/codex-rs/core/src/user_instructions.rs index 61f8d7fde4f..22b5ad7bbe5 100644 --- a/codex-rs/core/src/user_instructions.rs +++ b/codex-rs/core/src/user_instructions.rs @@ -6,6 +6,7 @@ use codex_protocol::models::ResponseItem; pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = ""; pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; +pub const SKILL_INSTRUCTIONS_PREFIX: &str = " for ResponseItem { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename = "skill_instructions", rename_all = "snake_case")] +pub(crate) struct SkillInstructions { + pub name: String, + pub path: String, + pub contents: String, +} + +impl SkillInstructions { + pub fn is_skill_instructions(message: &[ContentItem]) -> bool { + if let [ContentItem::InputText { text }] = message { + text.starts_with(SKILL_INSTRUCTIONS_PREFIX) + } else { + false + } + } +} + +impl From for ResponseItem { + fn from(si: SkillInstructions) -> Self { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "\n{}\n{}\n{}\n", + si.name, si.path, si.contents + ), + }], + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "developer_instructions", rename_all = "snake_case")] pub(crate) struct DeveloperInstructions { @@ -72,6 +106,7 @@ impl From for ResponseItem { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn test_user_instructions() { @@ -115,4 +150,44 @@ mod tests { } ])); } + + #[test] + fn test_skill_instructions() { + let skill_instructions = SkillInstructions { + name: "demo-skill".to_string(), + path: "skills/demo/SKILL.md".to_string(), + contents: "body".to_string(), + }; + let response_item: ResponseItem = skill_instructions.into(); + + let ResponseItem::Message { role, content, .. } = response_item else { + panic!("expected ResponseItem::Message"); + }; + + assert_eq!(role, "user"); + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected one InputText content item"); + }; + + assert_eq!( + text, + "\ndemo-skill\nskills/demo/SKILL.md\nbody\n", + ); + } + + #[test] + fn test_is_skill_instructions() { + assert!(SkillInstructions::is_skill_instructions(&[ + ContentItem::InputText { + text: "\ndemo-skill\nskills/demo/SKILL.md\nbody\n" + .to_string(), + } + ])); + assert!(!SkillInstructions::is_skill_instructions(&[ + ContentItem::InputText { + text: "regular text".to_string(), + } + ])); + } } diff --git a/codex-rs/core/templates/parallel/instructions.md b/codex-rs/core/templates/parallel/instructions.md deleted file mode 100644 index 292d585e45c..00000000000 --- a/codex-rs/core/templates/parallel/instructions.md +++ /dev/null @@ -1,13 +0,0 @@ - -## Exploration and reading files - -- **Think first.** Before any tool call, decide ALL files/resources you will need. -- **Batch everything.** If you need multiple files (even from different places), read them together. -- **multi_tool_use.parallel** Use `multi_tool_use.parallel` to parallelize tool calls and only this. -- **Only make sequential calls if you truly cannot know the next file without seeing a result first.** -- **Workflow:** (a) plan all needed reads → (b) issue one parallel batch → (c) analyze results → (d) repeat if new, unpredictable reads arise. - -**Additional notes**: -* Always maximize parallelism. Never read files one-by-one unless logically unavoidable. -* This concern every read/list/search operations including, but not only, `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`, ... -* Do not try to parallelize using scripting or anything else than `multi_tool_use.parallel`. diff --git a/codex-rs/core/templates/sandboxing/assessment_prompt.md b/codex-rs/core/templates/sandboxing/assessment_prompt.md deleted file mode 100644 index dc59f47d7cc..00000000000 --- a/codex-rs/core/templates/sandboxing/assessment_prompt.md +++ /dev/null @@ -1,24 +0,0 @@ -You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk to help the user decide whether to approve command execution. Return strictly valid JSON with the keys: -- description (concise summary of command intent and potential effects, no more than one sentence, use present tense) -- risk_level ("low", "medium", or "high") -Risk level examples: -- low: read-only inspections, listing files, printing configuration, fetching artifacts from trusted sources -- medium: modifying project files, installing dependencies -- high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls -If information is insufficient, choose the most cautious risk level supported by the evidence. -Respond with JSON only, without markdown code fences or extra commentary. - ---- - -Command metadata: -Platform: {{ platform }} -Sandbox policy: {{ sandbox_policy }} -{% if let Some(roots) = filesystem_roots %} -Filesystem roots: {{ roots }} -{% endif %} -Working directory: {{ working_directory }} -Command argv: {{ command_argv }} -Command (joined): {{ command_joined }} -{% if let Some(message) = sandbox_failure_message %} -Sandbox failure message: {{ message }} -{% endif %} diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index db1407455a4..6bfad437833 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,8 +1,8 @@ +#![allow(clippy::expect_used)] + use std::sync::Arc; use codex_app_server_protocol::AuthMode; -use codex_core::AuthManager; -use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::LocalShellAction; use codex_core::LocalShellExecAction; @@ -73,12 +73,11 @@ async fn run_request(input: Vec) -> Value { let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -112,11 +111,15 @@ async fn run_request(input: Vec) -> Value { } } - let requests = match server.received_requests().await { - Some(reqs) => reqs, - None => panic!("request not made"), - }; - match requests[0].body_json() { + let all_requests = server.received_requests().await.expect("received requests"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); + let request = requests + .first() + .unwrap_or_else(|| panic!("expected POST request to /chat/completions")); + match request.body_json() { Ok(v) => v, Err(e) => panic!("invalid json body: {e}"), } diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 0351263ebba..9124d59d13c 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,6 +1,5 @@ use assert_matches::assert_matches; use codex_core::AuthManager; -use codex_core::openai_models::models_manager::ModelsManager; use std::sync::Arc; use tracing_test::traced_test; @@ -12,6 +11,7 @@ use codex_core::Prompt; use codex_core::ResponseEvent; use codex_core::ResponseItem; use codex_core::WireApi; +use codex_core::openai_models::models_manager::ModelsManager; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemContent; @@ -74,11 +74,11 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let auth_mode = auth_manager.get_auth_mode(); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 75af1b4dd69..09da4bc7016 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -11,7 +11,7 @@ path = "lib.rs" anyhow = { workspace = true } assert_cmd = { workspace = true } base64 = { workspace = true } -codex-core = { workspace = true } +codex-core = { workspace = true, features = ["test-support"] } codex-protocol = { workspace = true } notify = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index e7b1e71efa4..d643fb77fcf 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -369,3 +369,13 @@ macro_rules! skip_if_no_network { } }}; } + +#[macro_export] +macro_rules! skip_if_windows { + ($return_value:expr $(,)?) => {{ + if cfg!(target_os = "windows") { + println!("Skipping test because it cannot execute on Windows."); + return $return_value; + } + }}; +} diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index e42b4ac943b..b98b29625eb 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -677,9 +677,43 @@ pub async fn start_mock_server() -> MockServer { .await; // Provide a default `/models` response so tests remain hermetic when the client queries it. - let _ = mount_models_once(&server, ModelsResponse { models: Vec::new() }).await; + let _ = mount_models_once( + &server, + ModelsResponse { + models: Vec::new(), + etag: String::new(), + }, + ) + .await; + + server +} +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get all POST requests to `/responses` endpoints from the mock server. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_requests(server: &MockServer) -> Vec { server + .received_requests() + .await + .expect("mock server should not fail") + .into_iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/responses")) + .collect() +} + +// todo(aibrahim): remove this and use our search matching patterns directly +/// Get request bodies as JSON values from POST requests to `/responses` endpoints. +/// Filters out GET requests (e.g., `/models`) . +pub async fn get_responses_request_bodies(server: &MockServer) -> Vec { + get_responses_requests(server) + .await + .into_iter() + .map(|req| { + req.body_json::() + .expect("request body to be valid JSON") + }) + .collect() } #[derive(Clone)] @@ -762,6 +796,10 @@ pub async fn mount_sse_sequence(server: &MockServer, bodies: Vec) -> Res /// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call` /// in the `input` must have a matching output entry. fn validate_request_body_invariants(request: &wiremock::Request) { + // Skip GET requests (e.g., /models) + if request.method != "POST" || !request.url.path().ends_with("/responses") { + return; + } let Ok(body): Result = request.body_json() else { return; }; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 23bcadadf15..b07f4d37412 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,10 +23,12 @@ use tempfile::TempDir; use wiremock::MockServer; use crate::load_default_config_for_test; +use crate::responses::get_responses_request_bodies; use crate::responses::start_mock_server; use crate::wait_for_event; type ConfigMutator = dyn FnOnce(&mut Config) + Send; +type PreBuildHook = dyn FnOnce(&Path) + Send + 'static; /// A collection of different ways the model can output an apply_patch call #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -50,6 +52,7 @@ pub enum ShellModelOutput { pub struct TestCodexBuilder { config_mutators: Vec>, auth: CodexAuth, + pre_build_hooks: Vec>, } impl TestCodexBuilder { @@ -69,10 +72,18 @@ impl TestCodexBuilder { pub fn with_model(self, model: &str) -> Self { let new_model = model.to_string(); self.with_config(move |config| { - config.model = new_model.clone(); + config.model = Some(new_model.clone()); }) } + pub fn with_pre_build_hook(mut self, hook: F) -> Self + where + F: FnOnce(&Path) + Send + 'static, + { + self.pre_build_hooks.push(Box::new(hook)); + self + } + pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result { let home = Arc::new(TempDir::new()?); self.build_with_home(server, home, None).await @@ -96,7 +107,8 @@ impl TestCodexBuilder { let (config, cwd) = self.prepare_config(server, &home).await?; let auth = self.auth.clone(); - let conversation_manager = ConversationManager::with_auth(auth.clone()); + let conversation_manager = + ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone()); let new_conversation = match resume_from { Some(path) => { @@ -135,6 +147,9 @@ impl TestCodexBuilder { let mut config = load_default_config_for_test(home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; + for hook in self.pre_build_hooks.drain(..) { + hook(home.path()); + } if let Ok(cmd) = assert_cmd::Command::cargo_bin("codex") { config.codex_linux_sandbox_exe = Some(PathBuf::from(cmd.get_program().to_os_string())); } @@ -169,6 +184,10 @@ impl TestCodex { self.cwd.path() } + pub fn codex_home_path(&self) -> &Path { + self.config.codex_home.as_path() + } + pub fn workspace_path(&self, rel: impl AsRef) -> PathBuf { self.cwd_path().join(rel) } @@ -272,13 +291,7 @@ impl TestCodexHarness { } pub async fn request_bodies(&self) -> Vec { - self.server - .received_requests() - .await - .expect("requests") - .into_iter() - .map(|req| serde_json::from_slice(&req.body).expect("request body json")) - .collect() + get_responses_request_bodies(&self.server).await } pub async fn function_call_output_value(&self, call_id: &str) -> Value { @@ -355,5 +368,6 @@ pub fn test_codex() -> TestCodexBuilder { TestCodexBuilder { config_mutators: vec![], auth: CodexAuth::from_api_key("dummy"), + pre_build_hooks: vec![], } } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 02423f3dfdc..934c327a6c9 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -61,16 +61,16 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -153,17 +153,17 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); let conversation_id = ConversationId::new(); let auth_mode = AuthMode::ChatGPT; - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -239,7 +239,7 @@ async fn responses_respects_model_family_overrides_from_config() { let codex_home = TempDir::new().expect("failed to create TempDir"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_provider_id = provider.name.clone(); config.model_provider = provider.clone(); config.model_supports_reasoning_summaries = Some(true); @@ -247,19 +247,20 @@ async fn responses_respects_model_family_overrides_from_config() { config.model_reasoning_summary = ReasoningSummary::Detailed; let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = config.model.clone().expect("model configured"); let config = Arc::new(config); let conversation_id = ConversationId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let model_family = models_manager.construct_model_family(&config.model, &config); + let auth_mode = + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")).get_auth_mode(); + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), - auth_manager.get_auth_mode(), + auth_mode, false, "test".to_string(), ); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 880e74d9592..70c8aa4fa4f 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1250,3 +1250,94 @@ async fn apply_patch_change_context_disambiguates_target( assert_eq!(contents, "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n"); Ok(()) } + +/// Ensure that applying a patch can update a CRLF file with unicode characters. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(ApplyPatchModelOutput::Freeform)] +#[test_case(ApplyPatchModelOutput::Function)] +#[test_case(ApplyPatchModelOutput::Shell)] +#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] +#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] +async fn apply_patch_cli_updates_unicode_characters( + model_output: ApplyPatchModelOutput, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("unicode.txt"); + fs::write(&target, "first ⚠️\nsecond ❌\nthird 🔥\n")?; + + let patch = format!( + r#"*** Begin Patch +*** Update File: {} +@@ + first ⚠️ +-second ❌ ++SECOND ✅ +@@ + third 🔥 ++FOURTH +*** End of File +*** End Patch"#, + target.display() + ); + let call_id = "apply-unicode-update"; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + + harness + .submit("update unicode characters via apply_patch CLI") + .await?; + + let file_contents = fs::read(&target)?; + let content = String::from_utf8_lossy(&file_contents); + assert_eq!(content, "first ⚠️\nSECOND ✅\nthird 🔥\nFOURTH\n"); + Ok(()) +} + +/// Ensure that applying a patch via the CLI preserves CRLF line endings for +/// Windows-style inputs even when updating the file contents. +#[cfg(windows)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[test_case(ApplyPatchModelOutput::Freeform)] +#[test_case(ApplyPatchModelOutput::Function)] +#[test_case(ApplyPatchModelOutput::Shell)] +#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] +#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] +async fn apply_patch_cli_updates_crlf_file_preserves_line_endings( + model_output: ApplyPatchModelOutput, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let target = harness.path("crlf.txt"); + fs::write(&target, b"first\r\nsecond\r\nthird\r\n")?; + + let patch = format!( + r#"*** Begin Patch +*** Update File: {} +@@ + first +-second ++SECOND +@@ + third ++FOURTH +*** End of File +*** End Patch"#, + target.display() + ); + let call_id = "apply-crlf-update"; + mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await; + + harness + .submit("update crlf file via apply_patch CLI") + .await?; + + let file_contents = fs::read(&target)?; + let content = String::from_utf8_lossy(&file_contents); + assert!(content.contains("\r\n")); + assert_eq!(content, "first\r\nSECOND\r\nthird\r\nFOURTH\r\n"); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 4570e6a5b94..10a510af427 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -9,6 +9,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::user_input::UserInput; @@ -96,14 +97,14 @@ impl ActionKind { test: &TestCodex, server: &MockServer, call_id: &str, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result<(Value, Option)> { match self { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let command = format!("printf {content:?} > {path:?} && cat {path:?}"); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::FetchUrl { @@ -125,11 +126,11 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { - let event = shell_event(call_id, command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, command, 1_000, sandbox_permissions)?; Ok((event, Some(command.to_string()))) } ActionKind::RunUnifiedExecCommand { @@ -140,7 +141,7 @@ impl ActionKind { call_id, command, Some(1000), - with_escalated_permissions, + sandbox_permissions, *justification, )?; Ok((event, Some(command.to_string()))) @@ -156,7 +157,7 @@ impl ActionKind { let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); let command = shell_apply_patch_command(&patch); - let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?; Ok((event, Some(command))) } } @@ -181,14 +182,14 @@ fn shell_event( call_id: &str, command: &str, timeout_ms: u64, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result { let mut args = json!({ "command": command, "timeout_ms": timeout_ms, }); - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "shell_command", &args_str)) @@ -198,7 +199,7 @@ fn exec_command_event( call_id: &str, cmd: &str, yield_time_ms: Option, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, justification: Option<&str>, ) -> Result { let mut args = json!({ @@ -207,8 +208,8 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); } @@ -466,7 +467,7 @@ struct ScenarioSpec { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, action: ActionKind, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, features: Vec, model_override: Option<&'static str>, outcome: Outcome, @@ -637,7 +638,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -654,7 +655,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -671,7 +672,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -687,7 +688,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -702,7 +703,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -717,7 +718,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -733,7 +734,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -750,7 +751,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure_5_1.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -767,7 +768,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -787,7 +788,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -807,7 +808,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -824,7 +825,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -841,7 +842,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -861,7 +862,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -880,7 +881,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -895,7 +896,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -911,7 +912,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-blocked", response_body: "should-not-see", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -925,7 +926,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_denied.txt"), content: "should-not-write", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: None, outcome: Outcome::ExecApproval { @@ -946,7 +947,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -967,7 +968,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure_5_1.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -987,7 +988,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1006,7 +1007,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1025,7 +1026,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1045,7 +1046,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function.txt"), content: "function-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1062,7 +1063,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), content: "function-patch-danger", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::ApplyPatchFreeform], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1079,7 +1080,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), content: "function-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1099,7 +1100,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), content: "function-patch-outside-denied", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1119,7 +1120,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), content: "shell-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1139,7 +1140,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), content: "function-patch-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1159,7 +1160,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), content: "function-patch-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1178,7 +1179,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1198,7 +1199,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1218,7 +1219,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_never.txt"), content: "read-only-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1241,7 +1242,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1257,7 +1258,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ww_on_request.txt"), content: "workspace-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1274,7 +1275,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-blocked", response_body: "workspace-network-blocked", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1288,7 +1289,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), content: "workspace-on-request-outside", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1308,7 +1309,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-ok", response_body: "workspace-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1325,7 +1326,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), content: "workspace-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1345,7 +1346,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), content: "workspace-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1365,7 +1366,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_never.txt"), content: "workspace-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1389,7 +1390,7 @@ fn scenarios() -> Vec { command: "echo \"hello unified exec\"", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1407,7 +1408,7 @@ fn scenarios() -> Vec { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1426,7 +1427,7 @@ fn scenarios() -> Vec { command: "git reset --hard", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: None, outcome: Outcome::ExecApproval { @@ -1472,7 +1473,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let call_id = scenario.name; let (event, expected_command) = scenario .action - .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .prepare(&test, &server, call_id, scenario.sandbox_permissions) .await?; let _ = mount_sse_once( @@ -1578,7 +1579,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (first_event, expected_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_first, false) + .prepare( + &test, + &server, + call_id_first, + SandboxPermissions::UseDefault, + ) .await?; let expected_command = expected_command.expect("execpolicy amendment scenario should produce a shell command"); @@ -1656,7 +1662,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (second_event, second_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_second, false) + .prepare( + &test, + &server, + call_id_second, + SandboxPermissions::UseDefault, + ) .await?; assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a508ae6817c..faa9801f86f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -30,7 +30,12 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; -use core_test_support::responses; +use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::get_responses_requests; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::sse_failed; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -240,7 +245,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Mock server that will receive the resumed request let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; // Configure Codex to resume from our file let model_provider = ModelProviderInfo { @@ -253,8 +258,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let NewConversation { @@ -337,8 +344,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -360,7 +369,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -381,7 +393,7 @@ async fn includes_base_instructions_override_in_request() { skip_if_no_network!(); // Mock server let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -393,8 +405,10 @@ async fn includes_base_instructions_override_in_request() { config.base_instructions = Some("test instructions".to_string()); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -451,7 +465,10 @@ async fn chatgpt_auth_sends_correct_request() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, conversation_id, @@ -473,7 +490,10 @@ async fn chatgpt_auth_sends_correct_request() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); @@ -569,7 +589,7 @@ async fn includes_user_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -581,8 +601,10 @@ async fn includes_user_instructions_message_in_request() { config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -627,7 +649,7 @@ async fn skills_append_to_instructions_when_feature_enabled() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -648,8 +670,10 @@ async fn skills_append_to_instructions_when_feature_enabled() { config.features.enable(Feature::Skills); config.cwd = codex_home.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -695,7 +719,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -734,7 +758,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .build(&server) @@ -771,7 +795,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_fami skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -804,7 +828,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex().with_model("gpt-5.1").build(&server).await?; codex @@ -837,7 +861,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1-codex") .with_config(|config| { @@ -875,7 +899,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let TestCodex { codex, .. } = test_codex() .with_model("gpt-5.1") .with_config(|config| { @@ -914,7 +938,7 @@ async fn includes_developer_instructions_message_in_request() { skip_if_no_network!(); let server = MockServer::start().await; - let resp_mock = responses::mount_sse_once(&server, sse_completed("resp1")).await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; let model_provider = ModelProviderInfo { base_url: Some(format!("{}/v1", server.uri())), @@ -927,8 +951,10 @@ async fn includes_developer_instructions_message_in_request() { config.user_instructions = Some("be nice".to_string()); config.developer_instructions = Some("be useful".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1014,15 +1040,15 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model_provider = provider.clone(); let effort = config.model_reasoning_effort; let summary = config.model_reasoning_summary; + let model = ModelsManager::get_model_offline(config.model.as_deref()); + config.model = Some(model.clone()); let config = Arc::new(config); - + let model_family = ModelsManager::construct_model_family_offline(model.as_str(), &config); let conversation_id = ConversationId::new(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager.clone())); - let model_family = models_manager.construct_model_family(&config.model, &config); let otel_event_manager = OtelEventManager::new( conversation_id, - config.model.as_str(), + model.as_str(), model_family.slug.as_str(), None, Some("test@test.com".to_string()), @@ -1105,11 +1131,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { } } - let requests = server - .received_requests() - .await - .expect("mock server collected requests"); - assert_eq!(requests.len(), 1, "expected a single request"); + let requests = get_responses_requests(&server).await; + assert_eq!(requests.len(), 1, "expected a single POST request"); let body: serde_json::Value = requests[0] .body_json() .expect("request body to be valid JSON"); @@ -1130,7 +1153,7 @@ async fn token_count_includes_rate_limits_snapshot() { skip_if_no_network!(); let server = MockServer::start().await; - let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]); + let sse_body = sse(vec![ev_completed_with_tokens("resp_rate", 123)]); let response = ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") @@ -1156,7 +1179,10 @@ async fn token_count_includes_rate_limits_snapshot() { let mut config = load_default_config_for_test(&home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1363,10 +1389,10 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res const EFFECTIVE_CONTEXT_WINDOW: i64 = (272_000 * 95) / 100; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("trigger context window"), - responses::sse_failed( + sse_failed( "resp_context_window", "context_length_exceeded", "Your input exceeds the context window of this model. Please adjust your input and try again.", @@ -1374,7 +1400,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res ) .await; - responses::mount_sse_once_match( + mount_sse_once_match( &server, body_string_contains("seed turn"), sse_completed("resp_seed"), @@ -1383,7 +1409,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { - config.model = "gpt-5.1".to_string(); + config.model = Some("gpt-5.1".to_string()); config.model_context_window = Some(272_000); }) .build(&server) @@ -1507,7 +1533,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1585,7 +1614,10 @@ async fn env_var_overrides_loaded_auth() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); + let conversation_manager = ConversationManager::with_models_provider( + create_dummy_codex_auth(), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1663,8 +1695,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -1701,7 +1735,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Inspect the three captured requests. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)"); // Replace full-array compare with tail-only raw JSON compare using a single hard-coded value. diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index f5fe1a7df92..2bd156d6a86 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -5,6 +5,7 @@ use codex_core::protocol::ReviewDecision; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -31,7 +32,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { let args = serde_json::json!({ "command": "rm -rf delegated", "timeout_ms": 1000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }) .to_string(); let sse1 = sse(vec![ diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index aa74ec89782..521a76845ab 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -28,6 +28,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::get_responses_requests; use core_test_support::responses::mount_compact_json_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; @@ -135,7 +136,10 @@ async fn summarize_context_three_requests_and_instructions() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -329,7 +333,10 @@ async fn manual_compact_uses_custom_prompt() { config.model_provider = model_provider; config.compact_prompt = Some(custom_prompt.to_string()); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -344,7 +351,7 @@ async fn manual_compact_uses_custom_prompt() { assert_eq!(message, COMPACT_WARNING_MESSAGE); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.expect("collect requests"); + let requests = get_responses_requests(&server).await; let body = requests .iter() .find_map(|req| req.body_json::().ok()) @@ -409,7 +416,10 @@ async fn manual_compact_emits_api_and_local_token_usage_events() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -570,7 +580,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // collect the requests payloads from the model - let requests_payloads = server.received_requests().await.unwrap(); + let requests_payloads = get_responses_requests(&server).await; let body = requests_payloads[0] .body_json::() @@ -1050,7 +1060,10 @@ async fn auto_compact_runs_after_token_limit_hit() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1090,7 +1103,7 @@ async fn auto_compact_runs_after_token_limit_hit() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!( requests.len(), 5, @@ -1295,7 +1308,10 @@ async fn auto_compact_persists_rollout_entries() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, session_configured, @@ -1397,11 +1413,14 @@ async fn manual_compact_retries_after_context_window_error() { config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200_000); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1529,11 +1548,14 @@ async fn manual_compact_twice_preserves_latest_user_messages() { let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; set_test_compact_prompt(&mut config); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { @@ -1731,7 +1753,10 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ config.model_provider = model_provider; set_test_compact_prompt(&mut config); config.model_auto_compact_token_limit = Some(200); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -1771,10 +1796,8 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ "auto compact should not emit task lifecycle events" ); - let request_bodies: Vec = server - .received_requests() - .await - .unwrap() + let requests = get_responses_requests(&server).await; + let request_bodies: Vec = requests .into_iter() .map(|request| String::from_utf8(request.body).unwrap_or_default()) .collect(); @@ -1845,11 +1868,14 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() { config.model_context_window = Some(context_window); config.model_auto_compact_token_limit = Some(limit); - let codex = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")) - .new_conversation(config) - .await - .unwrap() - .conversation; + let codex = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ) + .new_conversation(config) + .await + .unwrap() + .conversation; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f81294baf30..5d3d9e4b8a7 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -26,6 +26,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; use core_test_support::wait_for_event; @@ -771,17 +772,11 @@ fn normalize_line_endings(value: &mut Value) { } async fn gather_request_bodies(server: &MockServer) -> Vec { - server - .received_requests() - .await - .expect("mock server should not fail") - .into_iter() - .map(|req| { - let mut value = req.body_json::().expect("valid JSON body"); - normalize_line_endings(&mut value); - value - }) - .collect() + let mut bodies = get_responses_request_bodies(server).await; + for body in &mut bodies { + normalize_line_endings(body); + } + bodies } async fn mount_initial_flow(server: &MockServer) { @@ -870,9 +865,12 @@ async fn start_test_conversation( config.model_provider = model_provider; config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); if let Some(model) = model { - config.model = model.to_string(); + config.model = Some(model.to_string()); } - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation, .. } = manager .new_conversation(config.clone()) .await diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 4e240f0a077..bab715ebd80 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -36,7 +36,7 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() let DeprecationNoticeEvent { summary, details } = notice; assert_eq!( summary, - "`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead." + "`use_experimental_unified_exec_tool` is deprecated. Use `[features].unified_exec` instead." .to_string(), ); assert_eq!( diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 6c2283107bd..c0934821570 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -8,6 +8,7 @@ use codex_core::exec::ExecToolCallOutput; use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use tempfile::TempDir; @@ -34,7 +35,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result<()> { - let manager = ConversationManager::with_auth(CodexAuth::from_api_key("sk-test")); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("sk-test"), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_api_key(); assert_eq!(expected_models, models); @@ -19,9 +27,13 @@ async fn list_models_returns_api_key_models() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_chatgpt_models() -> Result<()> { - let manager = - ConversationManager::with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let models = manager.list_models().await; + let codex_home = tempdir()?; + let config = load_default_config_for_test(&codex_home); + let manager = ConversationManager::with_models_provider( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + built_in_model_providers()["openai"].clone(), + ); + let models = manager.list_models(&config).await; let expected_models = expected_models_for_chatgpt(); assert_eq!(expected_models, models); @@ -34,6 +46,7 @@ fn expected_models_for_api_key() -> Vec { gpt_5_1_codex_max(), gpt_5_1_codex(), gpt_5_1_codex_mini(), + robin(), gpt_5_1(), ] } @@ -43,6 +56,7 @@ fn expected_models_for_chatgpt() -> Vec { gpt_5_1_codex_max(), gpt_5_1_codex(), gpt_5_1_codex_mini(), + robin(), gpt_5_1(), ] } @@ -128,6 +142,37 @@ fn gpt_5_1_codex_mini() -> ModelPreset { } } +fn robin() -> ModelPreset { + ModelPreset { + id: "robin".to_string(), + model: "robin".to_string(), + display_name: "robin".to_string(), + description: "Robin".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Balances speed with some reasoning; useful for straightforward queries and short explanations", + ), + effort( + ReasoningEffort::Medium, + "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + effort( + ReasoningEffort::XHigh, + "Extra high reasoning for complex problems", + ), + ], + is_default: false, + upgrade: None, + show_in_picker: true, + } +} + fn gpt_5_1() -> ModelPreset { ModelPreset { id: "gpt-5.1".to_string(), diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 86f417801ae..e047899d722 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -41,12 +41,16 @@ mod otel; mod prompt_caching; mod quota_exceeded; mod read_file; +mod remote_models; mod resume; mod review; mod rmcp_client; mod rollout_list_find; mod seatbelt; +mod shell_command; mod shell_serialization; +mod shell_snapshot; +mod skills; mod stream_error_allows_next_turn; mod stream_no_completed; mod text_encoding_fix; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index f67196312fc..53a45e67868 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -20,10 +20,12 @@ async fn override_turn_context_does_not_persist_when_config_exists() { .expect("seed config.toml"); let mut config = load_default_config_for_test(&codex_home); - config.model = "gpt-4o".to_string(); + config.model = Some("gpt-4o".to_string()); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await @@ -62,8 +64,10 @@ async fn override_turn_context_does_not_create_config_file() { let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let codex = conversation_manager .new_conversation(config) .await diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 95f2d35cd7b..94158df6d85 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -71,7 +71,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); config.features.disable(Feature::ApplyPatchFreeform); - config.model = "codex-mini-latest".to_string(); + config.model = Some("codex-mini-latest".to_string()); }) .build(&server) .await?; @@ -131,12 +131,20 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); + config.model = Some("gpt-5.1-codex-max".to_string()); }) .build(&server) .await?; let base_instructions = conversation_manager .get_models_manager() - .construct_model_family(&config.model, &config) + .construct_model_family( + config + .model + .as_deref() + .expect("test config should have a model"), + &config, + ) + .await .base_instructions .clone(); @@ -571,7 +579,12 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -581,7 +594,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -658,7 +671,12 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let req1 = mount_sse_once(&server, sse_completed("resp-1")).await; let req2 = mount_sse_once(&server, sse_completed("resp-2")).await; - let TestCodex { codex, config, .. } = test_codex() + let TestCodex { + codex, + config, + session_configured, + .. + } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); }) @@ -668,7 +686,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu let default_cwd = config.cwd.clone(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); + let default_model = session_configured.model; let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs new file mode 100644 index 00000000000..707ab6fa45a --- /dev/null +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -0,0 +1,344 @@ +#![cfg(not(target_os = "windows"))] +// unified exec is not supported on Windows OS +use std::sync::Arc; + +use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; +use codex_core::config::Config; +use codex_core::features::Feature; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ClientVersion; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::user_input::UserInput; +use core_test_support::load_default_config_for_test; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; +use wiremock::BodyPrintLimit; +use wiremock::MockServer; + +const REMOTE_MODEL_SLUG: &str = "codex-test"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let remote_model = ModelInfo { + slug: REMOTE_MODEL_SLUG.to_string(), + display_name: "Remote Test".to_string(), + description: Some("A remote model that requires the test shell".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: None, + }; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + etag: String::new(), + }, + ) + .await; + + let harness = build_remote_models_harness(&server, |config| { + config.features.enable(Feature::RemoteModels); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; + + let RemoteModelsHarness { + codex, + cwd, + config, + conversation_manager, + .. + } = harness; + + let models_manager = conversation_manager.get_models_manager(); + let available_model = + wait_for_model_available(&models_manager, REMOTE_MODEL_SLUG, &config).await; + + assert_eq!(available_model.model, REMOTE_MODEL_SLUG); + + let requests = models_mock.requests(); + assert_eq!( + requests.len(), + 1, + "expected a single /models refresh request for the remote models feature" + ); + assert_eq!(requests[0].url.path(), "/v1/models"); + + let family = models_manager + .construct_model_family(REMOTE_MODEL_SLUG, &config) + .await; + assert_eq!(family.shell_type, ConfigShellToolType::UnifiedExec); + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(REMOTE_MODEL_SLUG.to_string()), + effort: None, + summary: None, + }) + .await?; + + let call_id = "call"; + let args = json!({ + "cmd": "/bin/echo call", + "yield_time_ms": 250, + }); + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run call".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REMOTE_MODEL_SLUG.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandBegin(event) if event.call_id == call_id => Some(event.clone()), + _ => None, + }) + .await; + + assert_eq!(begin_event.source, ExecCommandSource::UnifiedExecStartup); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_models_apply_remote_base_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + + let model = "test-gpt-5-remote"; + + let remote_base = "Use the remote base instructions only."; + let remote_model = ModelInfo { + slug: model.to_string(), + display_name: "Parallel Remote".to_string(), + description: Some("A remote model with custom instructions".to_string()), + default_reasoning_level: ReasoningEffort::Medium, + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + minimal_client_version: ClientVersion(0, 1, 0), + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: Some(remote_base.to_string()), + }; + mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + etag: String::new(), + }, + ) + .await; + + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let harness = build_remote_models_harness(&server, |config| { + config.features.enable(Feature::RemoteModels); + config.model = Some("gpt-5.1".to_string()); + }) + .await?; + + let RemoteModelsHarness { + codex, + cwd, + config, + conversation_manager, + .. + } = harness; + + let models_manager = conversation_manager.get_models_manager(); + wait_for_model_available(&models_manager, model, &config).await; + + codex + .submit(Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: Some(model.to_string()), + effort: None, + summary: None, + }) + .await?; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello remote".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model.to_string(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let body = response_mock.single_request().body_json(); + let instructions = body["instructions"].as_str().unwrap(); + assert_eq!(instructions, remote_base); + + Ok(()) +} + +async fn wait_for_model_available( + manager: &Arc, + slug: &str, + config: &Config, +) -> ModelPreset { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(model) = { + let guard = manager.list_models(config).await; + guard.iter().find(|model| model.model == slug).cloned() + } { + return model; + } + if Instant::now() >= deadline { + panic!("timed out waiting for the remote model {slug} to appear"); + } + sleep(Duration::from_millis(25)).await; + } +} + +struct RemoteModelsHarness { + codex: Arc, + cwd: Arc, + config: Config, + conversation_manager: Arc, +} + +// todo(aibrahim): move this to with_model_provier in test_codex +async fn build_remote_models_harness( + server: &MockServer, + mutate_config: F, +) -> Result +where + F: FnOnce(&mut Config), +{ + let auth = CodexAuth::from_api_key("dummy"); + let home = Arc::new(TempDir::new()?); + let cwd = Arc::new(TempDir::new()?); + + let mut config = load_default_config_for_test(&home); + config.cwd = cwd.path().to_path_buf(); + config.features.enable(Feature::RemoteModels); + + let provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + config.model_provider = provider.clone(); + + mutate_config(&mut config); + + let conversation_manager = Arc::new(ConversationManager::with_models_provider(auth, provider)); + + let new_conversation = conversation_manager + .new_conversation(config.clone()) + .await?; + + Ok(RemoteModelsHarness { + codex: new_conversation.conversation, + cwd, + config, + conversation_manager, + }) +} diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index c8376e41099..cb83ab06dc5 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -4,6 +4,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::NewConversation; +use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InitialHistory; use codex_core::protocol::ResumedHistory; @@ -16,7 +17,11 @@ use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use tempfile::TempDir; -fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory { +fn resume_history( + config: &codex_core::config::Config, + previous_model: &str, + rollout_path: &std::path::Path, +) -> InitialHistory { let turn_ctx = TurnContextItem { cwd: config.cwd.clone(), approval_policy: config.approval_policy, @@ -38,7 +43,7 @@ async fn emits_warning_when_resumed_model_differs() { // Arrange a config with a current model and a prior rollout recorded under a different model. let home = TempDir::new().expect("tempdir"); let mut config = load_default_config_for_test(&home); - config.model = "current-model".to_string(); + config.model = Some("current-model".to_string()); // Ensure cwd is absolute (the helper sets it to the temp dir already). assert!(config.cwd.is_absolute()); @@ -47,7 +52,10 @@ async fn emits_warning_when_resumed_model_differs() { let initial_history = resume_history(&config, "previous-model", &rollout_path); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("test"), + config.model_provider.clone(), + ); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); // Act: resume the conversation. diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index b3a52cfa540..ca8af6ad1e2 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -23,6 +23,7 @@ use codex_core::review_format::render_review_output_text; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::responses::get_responses_requests; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -394,7 +395,7 @@ async fn review_uses_custom_review_model_from_config() { let codex_home = TempDir::new().unwrap(); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, &codex_home, |cfg| { - cfg.model = "gpt-4.1".to_string(); + cfg.model = Some("gpt-4.1".to_string()); cfg.review_model = "gpt-5.1".to_string(); }) .await; @@ -425,7 +426,10 @@ async fn review_uses_custom_review_model_from_config() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request body model equals the configured review model - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); assert_eq!(body["model"].as_str().unwrap(), "gpt-5.1"); @@ -543,7 +547,10 @@ async fn review_input_isolated_from_parent_history() { let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // Assert the request `input` contains the environment context followed by the user review prompt. - let request = &server.received_requests().await.unwrap()[0]; + let requests = get_responses_requests(&server).await; + let request = requests + .first() + .expect("expected POST request to /responses"); let body = request.body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); assert_eq!( @@ -673,7 +680,7 @@ async fn review_history_surfaces_in_parent_session() { // Inspect the second request (parent turn) input contents. // Parent turns include session initial messages (user_instructions, environment_context). // Critically, no messages from the review thread should appear. - let requests = server.received_requests().await.unwrap(); + let requests = get_responses_requests(&server).await; assert_eq!(requests.len(), 2); let body = requests[1].body_json::().unwrap(); let input = body["input"].as_array().expect("input array"); @@ -743,8 +750,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); conversation_manager .new_conversation(config) .await @@ -770,8 +779,10 @@ where let mut config = load_default_config_for_test(codex_home); config.model_provider = model_provider; mutator(&mut config); - let conversation_manager = - ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let conversation_manager = ConversationManager::with_models_provider( + CodexAuth::from_api_key("Test API Key"), + config.model_provider.clone(), + ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); conversation_manager diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cc653a9c56e..ef2fc16ede0 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -487,9 +487,13 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { // Chat Completions assertion: the second POST should include a tool role message // with an array `content` containing an item with the expected data URL. - let requests = server.received_requests().await.expect("requests captured"); + let all_requests = server.received_requests().await.expect("requests captured"); + let requests: Vec<_> = all_requests + .iter() + .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) + .collect(); assert!(requests.len() >= 2, "expected two chat completion calls"); - let second = &requests[1]; + let second = requests[1]; let body: Value = serde_json::from_slice(&second.body)?; let messages = body .get("messages") diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs new file mode 100644 index 00000000000..10e972b3de3 --- /dev/null +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use core_test_support::assert_regex_match; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_windows; +use core_test_support::test_codex::TestCodexBuilder; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use serde_json::json; + +fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec { + let args = json!({ + "command": command, + "timeout_ms": 2_000, + "login": login, + }); + + #[allow(clippy::expect_used)] + let arguments = serde_json::to_string(&args).expect("serialize shell command arguments"); + + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ] +} + +async fn shell_command_harness_with( + configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, +) -> Result { + let builder = configure(test_codex()).with_config(|config| { + config.include_apply_patch_tool = true; + }); + TestCodexHarness::with_builder(builder).await +} + +async fn mount_shell_responses( + harness: &TestCodexHarness, + call_id: &str, + command: &str, + login: Option, +) { + mount_sse_sequence(harness.server(), shell_responses(call_id, command, login)).await; +} + +fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> { + let normalized_output = output + .replace("\r\n", "\n") + .replace('\r', "\n") + .trim_end_matches('\n') + .to_string(); + + let expected_pattern = format!( + r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{expected}\n?$" + ); + + assert_regex_match(&expected_pattern, &normalized_output); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_works() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", None).await; + harness.submit("run the echo command").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-login-true"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(true)).await; + harness.submit("run the echo command with login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn output_without_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-login-false"; + mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(false)).await; + harness.submit("run the echo command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_line_output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-first-extra-login"; + mount_shell_responses( + &harness, + call_id, + "echo 'first line\nsecond line'", + Some(true), + ) + .await; + harness.submit("run the command with login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "first line\nsecond line")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_output_with_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_windows!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-second-extra-no-login"; + mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", None).await; + harness.submit("run the command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pipe_output_without_login() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + skip_if_windows!(Ok(())); + + let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.1")).await?; + + let call_id = "shell-command-call-third-extra-login-false"; + mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", Some(false)).await; + harness.submit("run the command without login").await?; + + let output = harness.function_call_stdout(call_id).await; + assert_shell_command_output(&output, "hello, world")?; + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs new file mode 100644 index 00000000000..cc9d4ee77c8 --- /dev/null +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -0,0 +1,230 @@ +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecCommandBeginEvent; +use codex_core::protocol::ExecCommandEndEvent; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +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::PathBuf; +use tokio::fs; + +#[derive(Debug)] +struct SnapshotRun { + begin: ExecCommandBeginEvent, + end: ExecCommandEndEvent, + snapshot_path: PathBuf, + snapshot_content: String, + codex_home: PathBuf, +} + +#[allow(clippy::expect_used)] +async fn run_snapshot_command(command: &str) -> Result { + let builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + config.features.enable(Feature::ShellSnapshot); + }); + let harness = TestCodexHarness::with_builder(builder).await?; + let args = json!({ + "cmd": command, + "yield_time_ms": 1000, + }); + let call_id = "shell-snapshot-exec"; + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(harness.server(), responses).await; + + let test = harness.test(); + let codex = test.codex.clone(); + let codex_home = test.home.path().to_path_buf(); + let session_model = test.session_configured.model.clone(); + let cwd = test.cwd_path().to_path_buf(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run unified exec with shell snapshot".into(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + let snapshot_arg = begin + .command + .iter() + .find(|arg| arg.contains("shell_snapshots")) + .expect("command includes shell snapshot path") + .to_owned(); + let snapshot_path = PathBuf::from(&snapshot_arg); + let snapshot_content = fs::read_to_string(&snapshot_path).await?; + + let end = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + Ok(SnapshotRun { + begin, + end, + snapshot_path, + snapshot_content, + codex_home, + }) +} + +fn normalize_newlines(text: &str) -> String { + text.replace("\r\n", "\n") +} + +fn assert_posix_snapshot_sections(snapshot: &str) { + assert!(snapshot.contains("# Snapshot file")); + assert!(snapshot.contains("aliases ")); + assert!(snapshot.contains("exports ")); + assert!(snapshot.contains("setopts ")); + assert!( + snapshot.contains("PATH"), + "snapshot should include PATH exports; snapshot={snapshot:?}" + ); +} + +#[cfg_attr(not(target_os = "linux"), ignore)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-linux"; + let run = run_snapshot_command(command).await?; + let stdout = normalize_newlines(&run.end.stdout); + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$0\" && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(run.end.exit_code, 0); + assert!( + stdout.contains("snapshot-linux"), + "stdout should contain snapshot marker; stdout={stdout:?}" + ); + + Ok(()) +} + +#[cfg_attr(not(target_os = "macos"), ignore)] +#[cfg_attr( + target_os = "macos", + ignore = "requires unrestricted networking on macOS" +)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn macos_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "echo snapshot-macos"; + let run = run_snapshot_command(command).await?; + + let shell_path = run + .begin + .command + .first() + .expect("shell path recorded") + .clone(); + assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-c")); + assert_eq!( + run.begin.command.get(2).map(String::as_str), + Some(". \"$0\" && exec \"$@\"") + ); + assert_eq!(run.begin.command.get(4), Some(&shell_path)); + assert_eq!(run.begin.command.get(5).map(String::as_str), Some("-c")); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert_posix_snapshot_sections(&run.snapshot_content); + assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-macos"); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} + +// #[cfg_attr(not(target_os = "windows"), ignore)] +#[ignore] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn windows_unified_exec_uses_shell_snapshot() -> Result<()> { + let command = "Write-Output snapshot-windows"; + let run = run_snapshot_command(command).await?; + + let snapshot_index = run + .begin + .command + .iter() + .position(|arg| arg.contains("shell_snapshots")) + .expect("snapshot argument exists"); + assert!(run.begin.command.iter().any(|arg| arg == "-NoProfile")); + assert!( + run.begin + .command + .iter() + .any(|arg| arg == "param($snapshot) . $snapshot; & @args") + ); + assert!(snapshot_index > 0); + assert_eq!(run.begin.command.last(), Some(&command.to_string())); + + assert!(run.snapshot_path.starts_with(&run.codex_home)); + assert!(run.snapshot_content.contains("# Snapshot file")); + assert!(run.snapshot_content.contains("# aliases ")); + assert!(run.snapshot_content.contains("# exports ")); + assert_eq!( + normalize_newlines(&run.end.stdout).trim(), + "snapshot-windows" + ); + assert_eq!(run.end.exit_code, 0); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs new file mode 100644 index 00000000000..d6ced3c1dc3 --- /dev/null +++ b/codex-rs/core/tests/suite/skills.rs @@ -0,0 +1,136 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::features::Feature; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use std::fs; +use std::path::Path; + +fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> std::path::PathBuf { + let skill_dir = home.join("skills").join(name); + fs::create_dir_all(&skill_dir).unwrap(); + let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); + let path = skill_dir.join("SKILL.md"); + fs::write(&path, contents).unwrap(); + path +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_turn_includes_skill_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let skill_body = "skill body"; + let mut builder = test_codex() + .with_config(|cfg| { + cfg.features.enable(Feature::Skills); + }) + .with_pre_build_hook(|home| { + write_skill(home, "demo", "demo skill", skill_body); + }); + let test = builder.build(&server).await?; + + let skill_path = test.codex_home_path().join("skills/demo/SKILL.md"); + let skill_path = std::fs::canonicalize(skill_path)?; + + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + items: vec![ + UserInput::Text { + text: "please use $demo".to_string(), + }, + UserInput::Skill { + name: "demo".to_string(), + path: skill_path.clone(), + }, + ], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + }) + .await?; + + core_test_support::wait_for_event(test.codex.as_ref(), |event| { + matches!(event, codex_core::protocol::EventMsg::TaskComplete(_)) + }) + .await; + + let request = mock.single_request(); + let user_texts = request.message_input_texts("user"); + let skill_path_str = skill_path.to_string_lossy(); + assert!( + user_texts.iter().any(|text| { + text.contains("\ndemo") + && text.contains("") + && text.contains(skill_body) + && text.contains(skill_path_str.as_ref()) + }), + "expected skill instructions in user input, got {user_texts:?}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skill_load_errors_surface_in_session_configured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex() + .with_config(|cfg| { + cfg.features.enable(Feature::Skills); + }) + .with_pre_build_hook(|home| { + let skill_dir = home.join("skills").join("broken"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "not yaml").unwrap(); + }); + let test = builder.build(&server).await?; + + let SkillLoadOutcomeInfo { skills, errors } = test + .session_configured + .skill_load_outcome + .as_ref() + .expect("skill outcome present"); + + assert!( + skills.is_empty(), + "expected no skills loaded, got {skills:?}" + ); + assert_eq!(errors.len(), 1, "expected one load error"); + let error_path = errors[0].path.to_string_lossy(); + assert!( + error_path.ends_with("skills/broken/SKILL.md"), + "unexpected error path: {error_path}" + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 1f743c422f2..94a08c2d928 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -10,6 +10,7 @@ use anyhow::Result; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -105,7 +106,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { let first_args = json!({ "command": command, "timeout_ms": 1_000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ "command": command, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5e8f5a8cdfb..15ce32e53f1 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1,4 +1,3 @@ -#![cfg(not(target_os = "windows"))] use std::collections::HashMap; use std::ffi::OsStr; use std::fs; @@ -19,11 +18,13 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; +use core_test_support::responses::get_responses_request_bodies; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; +use core_test_support::skip_if_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; @@ -155,6 +156,7 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let builder = test_codex().with_config(|config| { config.include_apply_patch_tool = true; @@ -227,6 +229,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { false } EventMsg::ExecCommandBegin(event) if event.call_id == call_id => { + println!("Saw it"); saw_exec_begin = true; false } @@ -279,6 +282,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -350,6 +354,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { async fn unified_exec_resolves_relative_workdir() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -427,6 +432,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { async fn unified_exec_respects_workdir_override() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -505,6 +511,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { async fn unified_exec_emits_exec_command_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -591,6 +598,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -642,16 +650,16 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { }) .await?; - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()), + let event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), _ => None, }) .await; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + let text = event.stdout; assert!( text.contains("HELLO-UEXEC"), - "delta chunk missing expected text: {text:?}" + "delta chunk missing expected text: {text:?}", ); wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; @@ -659,9 +667,119 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { +async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec-full-lifecycle"; + let args = json!({ + "cmd": "printf 'HELLO-FULL-LIFECYCLE'", + "yield_time_ms": 50, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "exercise full unified exec lifecycle".into(), + }], + 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, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut saw_delta_with_marker = 0; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev), + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => { + let text = String::from_utf8_lossy(&ev.chunk); + if text.contains("HELLO-FULL-LIFECYCLE") { + saw_delta_with_marker += 1; + } + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => { + assert!( + end_event.is_none(), + "expected a single ExecCommandEnd event for this call id" + ); + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); + assert_eq!(begin_event.call_id, call_id); + assert!( + begin_event.process_id.is_some(), + "begin event should include a process_id for a long-lived session" + ); + + assert_eq!( + saw_delta_with_marker, 0, + "no ExecCommandOutputDelta should be sent for early exit commands" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include process_id emitted by background watcher" + ); + assert!( + end_event.aggregated_output.contains("HELLO-FULL-LIFECYCLE"), + "aggregated_output should contain the full PTY transcript; got {:?}", + end_event.aggregated_output + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -733,34 +851,34 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { }) .await?; - // Expect a delta event corresponding to the write_stdin call. - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { - let text = String::from_utf8_lossy(&ev.chunk); - if text.contains("WSTDIN-MARK") { - Some(ev.clone()) - } else { - None + let mut terminal_interaction = None; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_interaction = Some(ev); } + EventMsg::TaskComplete(_) => break, + _ => {} } - _ => None, - }) - .await; - - let text = String::from_utf8_lossy(&delta.chunk).to_string(); - assert!( - text.contains("WSTDIN-MARK"), - "stdin delta chunk missing expected text: {text:?}" - ); + } - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + let delta = terminal_interaction.expect("expected TerminalInteraction event"); + assert_eq!(delta.process_id, "1000"); + let expected_stdin = stdin_args + .get("chars") + .and_then(Value::as_str) + .expect("stdin chars"); + assert_eq!(delta.stdin, expected_stdin); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { +async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -775,18 +893,33 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { .. } = builder.build(&server).await?; - let open_call_id = "uexec-open-for-begin"; + let open_call_id = "uexec-delayed-open"; let open_args = json!({ - "shell": "bash".to_string(), - "cmd": "bash -i".to_string(), - "yield_time_ms": 200, + "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", + "yield_time_ms": 10, }); - let stdin_call_id = "uexec-stdin-begin"; - let stdin_args = json!({ - "chars": "echo hello", + // Poll stdin three times: first for no output, second after the first marker, + // and a final long poll to capture the second marker. + let first_poll_call_id = "uexec-delayed-poll-1"; + let first_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10, + }); + + let second_poll_call_id = "uexec-delayed-poll-2"; + let second_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 4000, + }); + + let third_poll_call_id = "uexec-delayed-poll-3"; + let third_poll_args = json!({ + "chars": "", "session_id": 1000, - "yield_time_ms": 400, + "yield_time_ms": 6000, }); let responses = vec![ @@ -802,17 +935,35 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { sse(vec![ ev_response_created("resp-2"), ev_function_call( - stdin_call_id, + first_poll_call_id, "write_stdin", - &serde_json::to_string(&stdin_args)?, + &serde_json::to_string(&first_poll_args)?, ), ev_completed("resp-2"), ]), sse(vec![ ev_response_created("resp-3"), - ev_assistant_message("msg-1", "done"), + ev_function_call( + second_poll_call_id, + "write_stdin", + &serde_json::to_string(&second_poll_args)?, + ), ev_completed("resp-3"), ]), + sse(vec![ + ev_response_created("resp-4"), + ev_function_call( + third_poll_call_id, + "write_stdin", + &serde_json::to_string(&third_poll_args)?, + ), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-1", "complete"), + ev_completed("resp-5"), + ]), ]; mount_sse_sequence(&server, responses).await; @@ -821,7 +972,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { codex .submit(Op::UserTurn { items: vec![UserInput::Text { - text: "begin events for stdin".into(), + text: "delayed terminal interaction output".into(), }], final_output_json_schema: None, cwd: cwd.path().to_path_buf(), @@ -833,30 +984,91 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> { }) .await?; - let begin_event = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandBegin(ev) if ev.call_id == stdin_call_id => Some(ev.clone()), - _ => None, - }) - .await; + let mut begin_event = None; + let mut end_event = None; + let mut task_completed = false; + let mut terminal_events = Vec::new(); + let mut delta_text = String::new(); - assert_command(&begin_event.command, "-lc", "bash -i"); + // Consume all events for this turn so we can assert on each stage. + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == open_call_id => { + begin_event = Some(ev); + } + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { + delta_text.push_str(&String::from_utf8_lossy(&ev.chunk)); + } + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_events.push(ev); + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => { + task_completed = true; + } + _ => {} + }; + if task_completed && end_event.is_some() { + break; + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); + assert!( + begin_event.process_id.is_some(), + "begin event should include process_id for a live session" + ); + + // We expect three terminal interactions matching the three write_stdin calls. assert_eq!( - begin_event.interaction_input, - Some("echo hello".to_string()) + terminal_events.len(), + 3, + "expected three terminal interactions; got {terminal_events:?}" ); + + for event in &terminal_events { + assert_eq!(event.call_id, open_call_id); + assert_eq!(event.process_id, "1000"); + } assert_eq!( - begin_event.source, - ExecCommandSource::UnifiedExecInteraction + terminal_events + .iter() + .map(|ev| ev.stdin.as_str()) + .collect::>(), + vec!["", "", ""], + "terminal interactions should reflect the three stdin polls" + ); + + assert!( + delta_text.contains("MARKER1") && delta_text.contains("MARKER2"), + "streamed deltas should contain both markers; got {delta_text:?}" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, open_call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include the process_id" + ); + assert!( + end_event.aggregated_output.contains("MARKER1") + && end_event.aggregated_output.contains("MARKER2"), + "aggregated output should include both markers in order; got {:?}", + end_event.aggregated_output ); - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> { +async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -874,8 +1086,8 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> let open_call_id = "uexec-open-session"; let open_args = json!({ "shell": "bash".to_string(), - "cmd": "bash -i".to_string(), - "yield_time_ms": 250, + "cmd": "sleep 0.1".to_string(), + "yield_time_ms": 10, }); let poll_call_id = "uexec-poll-empty"; @@ -930,10 +1142,12 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> .await?; let mut begin_events = Vec::new(); + let mut end_events = Vec::new(); loop { let event_msg = wait_for_event(&codex, |_| true).await; match event_msg { EventMsg::ExecCommandBegin(event) => begin_events.push(event), + EventMsg::ExecCommandEnd(event) => end_events.push(event), EventMsg::TaskComplete(_) => break, _ => {} } @@ -941,16 +1155,19 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> assert_eq!( begin_events.len(), - 2, - "expected begin events for the startup command and the write_stdin call" + 1, + "expected begin events for the startup command" ); - let open_event = begin_events - .iter() - .find(|ev| ev.call_id == open_call_id) - .expect("missing exec_command begin"); + assert_eq!( + end_events.len(), + 1, + "expected end event for the write_stdin call" + ); - assert_command(&open_event.command, "-lc", "bash -i"); + let open_event = &begin_events[0]; + + assert_command(&open_event.command, "-lc", "sleep 0.1"); assert!( open_event.interaction_input.is_none(), @@ -958,18 +1175,8 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> ); assert_eq!(open_event.source, ExecCommandSource::UnifiedExecStartup); - let poll_event = begin_events - .iter() - .find(|ev| ev.call_id == poll_call_id) - .expect("missing write_stdin begin"); - - assert_command(&poll_event.command, "-lc", "bash -i"); - - assert!( - poll_event.interaction_input.is_none(), - "poll begin events should omit interaction input" - ); - assert_eq!(poll_event.source, ExecCommandSource::UnifiedExecInteraction); + let end_event = &end_events[0]; + assert_eq!(end_event.call_id, open_call_id); Ok(()) } @@ -978,6 +1185,7 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()> async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1033,10 +1241,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let metadata = outputs @@ -1085,6 +1290,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { async fn unified_exec_respects_early_exit_notifications() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1139,10 +1345,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs @@ -1177,6 +1380,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1266,10 +1470,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1338,6 +1539,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1442,6 +1644,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() async fn unified_exec_reuses_session_via_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1516,10 +1719,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1553,6 +1753,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { async fn unified_exec_streams_after_lagged_output() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1652,10 +1853,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1684,6 +1882,7 @@ PY async fn unified_exec_timeout_and_followup_poll() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1763,10 +1962,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; @@ -1790,6 +1986,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { async fn unified_exec_formats_large_output_summary() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1851,10 +2048,7 @@ PY let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let large_output = outputs.get(call_id).expect("missing large output summary"); @@ -1875,6 +2069,7 @@ PY async fn unified_exec_runs_under_sandbox() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; @@ -1930,10 +2125,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let output = outputs.get(call_id).expect("missing output"); @@ -2031,10 +2223,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let requests = server.received_requests().await.expect("recorded requests"); assert!(!requests.is_empty(), "expected at least one POST request"); - let bodies = requests - .iter() - .map(|req| req.body_json::().expect("request json")) - .collect::>(); + let bodies = get_responses_request_bodies(&server).await; let outputs = collect_tool_outputs(&bodies)?; let startup_output = outputs @@ -2067,11 +2256,80 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_runs_on_all_platforms() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "uexec"; + let args = serde_json::json!({ + "cmd": "echo 'hello crossplat'", + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "summarize large output".into(), + }], + 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, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = get_responses_request_bodies(&server).await; + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs.get(call_id).expect("missing output"); + + // TODO: Weaker match because windows produces control characters + assert_regex_match(".*hello crossplat.*", &output.output); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 964cc58d506..8472399ce42 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -42,8 +42,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. @@ -99,8 +101,10 @@ async fn user_shell_cmd_can_be_interrupted() { // Set up isolated config and conversation. let codex_home = TempDir::new().unwrap(); let config = load_default_config_for_test(&codex_home); - let conversation_manager = - ConversationManager::with_auth(codex_core::CodexAuth::from_api_key("dummy")); + let conversation_manager = ConversationManager::with_models_provider( + codex_core::CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + ); let NewConversation { conversation: codex, .. diff --git a/codex-rs/default.nix b/codex-rs/default.nix index 867e57ee2d0..819ca7aad27 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -22,7 +22,7 @@ rustPlatform.buildRustPackage (_: { cargoLock.outputHashes = { "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; "crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728="; - "rmcp-0.9.0" = "sha256-0iPrpf0Ha/facO3p5e0hUKHBqGp/iS+C+OdS+pRKMOU="; + "filedescriptor-0.8.3" = "sha256-aIbzfHYjPDzWSZrgbauezGzg6lm3frhyBbU01gTQpaE="; }; meta = with lib; { diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index ab6ca80a120..a0bd5349343 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -56,5 +56,9 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] +assert_cmd = { workspace = true } +exec_server_test_support = { workspace = true } +maplit = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +which = { workspace = true } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index adec09d4dec..62f7bbccca9 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -6,3 +6,6 @@ pub use posix::main_execve_wrapper; #[cfg(unix)] pub use posix::main_mcp_server; + +#[cfg(unix)] +pub use posix::ExecResult; diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 16da5885f53..ba481264e2f 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -63,6 +63,7 @@ use anyhow::Context as _; use clap::Parser; use codex_core::config::find_codex_home; use codex_core::is_dangerous_command::command_might_be_dangerous; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; @@ -82,6 +83,8 @@ mod mcp_escalation_policy; mod socket; mod stopwatch; +pub use mcp::ExecResult; + /// Default value of --execve option relative to the current executable. /// Note this must match the name of the binary as specified in Cargo.toml. const CODEX_EXECVE_WRAPPER_EXE_NAME: &str = "codex-execve-wrapper"; @@ -200,13 +203,19 @@ pub(crate) fn evaluate_exec_policy( && rule_match.decision() == evaluation.decision }); + let sandbox_permissions = if decision_driven_by_policy { + SandboxPermissions::RequireEscalated + } else { + SandboxPermissions::UseDefault + }; + Ok(match evaluation.decision { Decision::Forbidden => ExecPolicyOutcome::Forbidden, Decision::Prompt => ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, Decision::Allow => ExecPolicyOutcome::Allow { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, }) } @@ -229,6 +238,7 @@ async fn load_exec_policy() -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; @@ -245,7 +255,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: false + sandbox_permissions: SandboxPermissions::UseDefault } ); } @@ -274,7 +284,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Allow { - run_with_escalated_permissions: true + sandbox_permissions: SandboxPermissions::RequireEscalated } ); } diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index b71142d5b14..d99f3007040 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -10,6 +10,7 @@ use path_absolutize::Absolutize as _; use codex_core::SandboxState; use codex_core::exec::process_exec_tool_call; +use codex_core::sandboxing::SandboxPermissions; use tokio::process::Command; use tokio_util::sync::CancellationToken; @@ -85,7 +86,7 @@ impl EscalateServer { cwd: PathBuf::from(&workdir), expiration: ExecExpiration::Cancellation(cancel_rx), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }, @@ -258,12 +259,18 @@ mod tests { }), )); + let mut env = HashMap::new(); + for i in 0..10 { + let value = "A".repeat(1024); + env.insert(format!("CODEX_TEST_VAR{i}"), value); + } + client .send(EscalateRequest { file: PathBuf::from("/bin/echo"), argv: vec!["echo".to_string()], workdir: PathBuf::from("/tmp"), - env: HashMap::new(), + env, }) .await?; diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index bbbddc22e61..1376d46b721 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -54,7 +54,7 @@ pub struct ExecParams { pub login: Option, } -#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct ExecResult { pub exit_code: i32, pub output: String, diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs index 97e76a68447..6d0c1bb3380 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs @@ -1,5 +1,6 @@ use std::path::Path; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Policy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; @@ -18,10 +19,10 @@ use tokio::sync::RwLock; #[derive(Debug, PartialEq, Eq)] pub(crate) enum ExecPolicyOutcome { Allow { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Prompt { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Forbidden, } @@ -108,16 +109,16 @@ impl EscalationPolicy for McpEscalationPolicy { crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; let action = match outcome { ExecPolicyOutcome::Allow { - run_with_escalated_permissions, + sandbox_permissions, } => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run } } ExecPolicyOutcome::Prompt { - run_with_escalated_permissions, + sandbox_permissions, } => { let result = self .prompt(file, argv, workdir, self.context.clone()) @@ -125,7 +126,7 @@ impl EscalationPolicy for McpEscalationPolicy { // TODO: Extract reason from `result.content`. match result.action { ElicitationAction::Accept => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run diff --git a/codex-rs/exec-server/src/posix/socket.rs b/codex-rs/exec-server/src/posix/socket.rs index 92c93dcc7d6..35292367a6b 100644 --- a/codex-rs/exec-server/src/posix/socket.rs +++ b/codex-rs/exec-server/src/posix/socket.rs @@ -171,42 +171,24 @@ async fn read_frame_payload( unreachable!("loop exits only after returning payload") } -fn send_message_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { - if fds.len() > MAX_FDS_PER_MESSAGE { +fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { + let control = make_control_message(fds)?; + let payload = [IoSlice::new(data)]; + let msg = if control.is_empty() { + MsgHdr::new().with_buffers(&payload) + } else { + MsgHdr::new().with_buffers(&payload).with_control(&control) + }; + let written = socket.sendmsg(&msg, 0)?; + if written != data.len() { return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("too many fds: {}", fds.len()), + std::io::ErrorKind::WriteZero, + format!( + "short datagram write: wrote {written} bytes out of {}", + data.len() + ), )); } - let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + data.len()); - frame.extend_from_slice(&encode_length(data.len())?); - frame.extend_from_slice(data); - - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - unsafe { - let cmsg = control.as_mut_ptr().cast::(); - (*cmsg).cmsg_len = libc::CMSG_LEN(size_of::() as c_uint * fds.len() as c_uint) as _; - (*cmsg).cmsg_level = libc::SOL_SOCKET; - (*cmsg).cmsg_type = libc::SCM_RIGHTS; - let data_ptr = libc::CMSG_DATA(cmsg).cast::(); - for (i, fd) in fds.iter().enumerate() { - data_ptr.add(i).write(fd.as_raw_fd()); - } - } - - let payload = [IoSlice::new(&frame)]; - let msg = MsgHdr::new().with_buffers(&payload).with_control(&control); - let mut sent = socket.sendmsg(&msg, 0)?; - while sent < frame.len() { - let bytes = socket.send(&frame[sent..])?; - if bytes == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - "socket closed while sending frame payload", - )); - } - sent += bytes; - } Ok(()) } @@ -220,24 +202,16 @@ fn encode_length(len: usize) -> std::io::Result<[u8; LENGTH_PREFIX_SIZE]> { Ok(len_u32.to_le_bytes()) } -pub(crate) fn send_json_message( - socket: &Socket, - msg: T, - fds: &[OwnedFd], -) -> std::io::Result<()> { - let data = serde_json::to_vec(&msg)?; - send_message_bytes(socket, &data, fds) -} - -fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io::Result<()> { +fn make_control_message(fds: &[OwnedFd]) -> std::io::Result> { if fds.len() > MAX_FDS_PER_MESSAGE { - return Err(std::io::Error::new( + Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("too many fds: {}", fds.len()), - )); - } - let mut control = vec![0u8; control_space_for_fds(fds.len())]; - if !fds.is_empty() { + )) + } else if fds.is_empty() { + Ok(Vec::new()) + } else { + let mut control = vec![0u8; control_space_for_fds(fds.len())]; unsafe { let cmsg = control.as_mut_ptr().cast::(); (*cmsg).cmsg_len = @@ -249,20 +223,8 @@ fn send_datagram_bytes(socket: &Socket, data: &[u8], fds: &[OwnedFd]) -> std::io data_ptr.add(i).write(fd.as_raw_fd()); } } + Ok(control) } - let payload = [IoSlice::new(data)]; - let msg = MsgHdr::new().with_buffers(&payload).with_control(&control); - let written = socket.sendmsg(&msg, 0)?; - if written != data.len() { - return Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - format!( - "short datagram write: wrote {written} bytes out of {}", - data.len() - ), - )); - } - Ok(()) } fn receive_datagram_bytes(socket: &Socket) -> std::io::Result<(Vec, Vec)> { @@ -308,11 +270,11 @@ impl AsyncSocket { msg: T, fds: &[OwnedFd], ) -> std::io::Result<()> { - self.inner - .async_io(Interest::WRITABLE, |socket| { - send_json_message(socket, &msg, fds) - }) - .await + let payload = serde_json::to_vec(&msg)?; + let mut frame = Vec::with_capacity(LENGTH_PREFIX_SIZE + payload.len()); + frame.extend_from_slice(&encode_length(payload.len())?); + frame.extend_from_slice(&payload); + send_stream_frame(&self.inner, &frame, fds).await } pub async fn receive_with_fds Deserialize<'de>>( @@ -343,6 +305,54 @@ impl AsyncSocket { } } +async fn send_stream_frame( + socket: &AsyncFd, + frame: &[u8], + fds: &[OwnedFd], +) -> std::io::Result<()> { + let mut written = 0; + let mut include_fds = !fds.is_empty(); + while written < frame.len() { + let mut guard = socket.writable().await?; + let result = guard.try_io(|inner| { + send_stream_chunk(inner.get_ref(), &frame[written..], fds, include_fds) + }); + let bytes_written = match result { + Ok(bytes_written) => bytes_written?, + Err(_would_block) => continue, + }; + if bytes_written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "socket closed while sending frame payload", + )); + } + written += bytes_written; + include_fds = false; + } + Ok(()) +} + +fn send_stream_chunk( + socket: &Socket, + frame: &[u8], + fds: &[OwnedFd], + include_fds: bool, +) -> std::io::Result { + let control = if include_fds { + make_control_message(fds)? + } else { + Vec::new() + }; + let payload = [IoSlice::new(frame)]; + let msg = if control.is_empty() { + MsgHdr::new().with_buffers(&payload) + } else { + MsgHdr::new().with_buffers(&payload).with_control(&control) + }; + socket.sendmsg(&msg, 0) +} + pub(crate) struct AsyncDatagramSocket { inner: AsyncFd, } @@ -433,6 +443,17 @@ mod tests { Ok(()) } + #[tokio::test] + async fn async_socket_handles_large_payload() -> std::io::Result<()> { + let (server, client) = AsyncSocket::pair()?; + let payload = vec![b'A'; 10_000]; + let receive_task = tokio::spawn(async move { server.receive::>().await }); + client.send(payload.clone()).await?; + let received_payload = receive_task.await.unwrap()?; + assert_eq!(payload, received_payload); + Ok(()) + } + #[tokio::test] async fn async_datagram_sockets_round_trip_messages() -> std::io::Result<()> { let (server, client) = AsyncDatagramSocket::pair()?; @@ -450,19 +471,19 @@ mod tests { } #[test] - fn send_message_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?; + fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { + let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?; let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_message_bytes(&socket, b"hello", &fds).unwrap_err(); + let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); Ok(()) } #[test] - fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> { - let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?; + fn send_stream_chunk_rejects_excessive_fd_counts() -> std::io::Result<()> { + let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?; let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?; - let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err(); + let err = send_stream_chunk(&socket, b"hello", &fds, true).unwrap_err(); assert_eq!(std::io::ErrorKind::InvalidInput, err.kind()); Ok(()) } diff --git a/codex-rs/exec-server/tests/all.rs b/codex-rs/exec-server/tests/all.rs new file mode 100644 index 00000000000..7e136e4cce2 --- /dev/null +++ b/codex-rs/exec-server/tests/all.rs @@ -0,0 +1,3 @@ +// Single integration test binary that aggregates all test modules. +// The submodules live in `tests/suite/`. +mod suite; diff --git a/codex-rs/exec-server/tests/common/Cargo.toml b/codex-rs/exec-server/tests/common/Cargo.toml new file mode 100644 index 00000000000..ba7d2af0a17 --- /dev/null +++ b/codex-rs/exec-server/tests/common/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "exec_server_test_support" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "lib.rs" + +[dependencies] +assert_cmd = { workspace = true } +anyhow = { workspace = true } +codex-core = { workspace = true } +rmcp = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs new file mode 100644 index 00000000000..0c295282209 --- /dev/null +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -0,0 +1,182 @@ +use codex_core::MCP_SANDBOX_STATE_NOTIFICATION; +use codex_core::SandboxState; +use codex_core::protocol::SandboxPolicy; +use rmcp::ClientHandler; +use rmcp::ErrorData as McpError; +use rmcp::RoleClient; +use rmcp::Service; +use rmcp::model::ClientCapabilities; +use rmcp::model::ClientInfo; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::CreateElicitationResult; +use rmcp::model::CustomClientNotification; +use rmcp::model::ElicitationAction; +use rmcp::service::RunningService; +use rmcp::transport::ConfigureCommandExt; +use rmcp::transport::TokioChildProcess; +use serde_json::json; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; +use tokio::process::Command; + +pub async fn create_transport

( + codex_home: P, + dotslash_cache: P, +) -> anyhow::Result +where + P: AsRef, +{ + let mcp_executable = assert_cmd::Command::cargo_bin("codex-exec-mcp-server")?; + let execve_wrapper = assert_cmd::Command::cargo_bin("codex-execve-wrapper")?; + let bash = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("tests") + .join("suite") + .join("bash"); + + // Need to ensure the artifact associated with the bash DotSlash file is + // available before it is run in a read-only sandbox. + let status = Command::new("dotslash") + .arg("--") + .arg("fetch") + .arg(bash.clone()) + .env("DOTSLASH_CACHE", dotslash_cache.as_ref()) + .status() + .await?; + assert!(status.success(), "dotslash fetch failed: {status:?}"); + + let transport = + TokioChildProcess::new(Command::new(mcp_executable.get_program()).configure(|cmd| { + cmd.arg("--bash").arg(bash); + cmd.arg("--execve").arg(execve_wrapper.get_program()); + cmd.env("CODEX_HOME", codex_home.as_ref()); + cmd.env("DOTSLASH_CACHE", dotslash_cache.as_ref()); + + // Important: pipe stdio so rmcp can speak JSON-RPC over stdin/stdout + cmd.stdin(Stdio::piped()); + cmd.stdout(Stdio::piped()); + + // Optional but very helpful while debugging: + cmd.stderr(Stdio::inherit()); + }))?; + + Ok(transport) +} + +pub async fn write_default_execpolicy

(policy: &str, codex_home: P) -> anyhow::Result<()> +where + P: AsRef, +{ + let policy_dir = codex_home.as_ref().join("policy"); + tokio::fs::create_dir_all(&policy_dir).await?; + tokio::fs::write(policy_dir.join("default.codexpolicy"), policy).await?; + Ok(()) +} + +pub async fn notify_readable_sandbox( + sandbox_cwd: P, + codex_linux_sandbox_exe: Option, + service: &RunningService, +) -> anyhow::Result<()> +where + P: AsRef, + S: Service + ClientHandler, +{ + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::ReadOnly, + codex_linux_sandbox_exe, + sandbox_cwd: sandbox_cwd.as_ref().to_path_buf(), + }; + send_sandbox_notification(sandbox_state, service).await +} + +pub async fn notify_writable_sandbox_only_one_folder( + writable_folder: P, + codex_linux_sandbox_exe: Option, + service: &RunningService, +) -> anyhow::Result<()> +where + P: AsRef, + S: Service + ClientHandler, +{ + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::WorkspaceWrite { + // Note that sandbox_cwd will already be included as a writable root + // when the sandbox policy is expanded. + writable_roots: vec![], + network_access: false, + // Disable writes to temp dir because this is a test, so + // writable_folder is likely also under /tmp and we want to be + // strict about what is writable. + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + codex_linux_sandbox_exe, + sandbox_cwd: writable_folder.as_ref().to_path_buf(), + }; + send_sandbox_notification(sandbox_state, service).await +} + +async fn send_sandbox_notification( + sandbox_state: SandboxState, + service: &RunningService, +) -> anyhow::Result<()> +where + S: Service + ClientHandler, +{ + let sandbox_state_notification = CustomClientNotification::new( + MCP_SANDBOX_STATE_NOTIFICATION, + Some(serde_json::to_value(sandbox_state)?), + ); + service + .send_notification(sandbox_state_notification.into()) + .await?; + Ok(()) +} + +pub struct InteractiveClient { + pub elicitations_to_accept: HashSet, + pub elicitation_requests: Arc>>, +} + +impl ClientHandler for InteractiveClient { + fn get_info(&self) -> ClientInfo { + let capabilities = ClientCapabilities::builder().enable_elicitation().build(); + ClientInfo { + capabilities, + ..Default::default() + } + } + + fn create_elicitation( + &self, + request: CreateElicitationRequestParam, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + self.elicitation_requests + .lock() + .unwrap() + .push(request.clone()); + + let accept = self.elicitations_to_accept.contains(&request.message); + async move { + if accept { + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content: Some(json!({ "approve": true })), + }) + } else { + Ok(CreateElicitationResult { + action: ElicitationAction::Decline, + content: None, + }) + } + } + } +} diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs new file mode 100644 index 00000000000..a9c6978a0ed --- /dev/null +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -0,0 +1,176 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::borrow::Cow; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use codex_exec_server::ExecResult; +use exec_server_test_support::InteractiveClient; +use exec_server_test_support::create_transport; +use exec_server_test_support::notify_readable_sandbox; +use exec_server_test_support::write_default_execpolicy; +use maplit::hashset; +use pretty_assertions::assert_eq; +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParam; +use rmcp::model::CallToolResult; +use rmcp::model::CreateElicitationRequestParam; +use rmcp::model::object; +use serde_json::json; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::symlink; +use tempfile::TempDir; + +/// Verify that when using a read-only sandbox and an execpolicy that prompts, +/// the proper elicitation is sent. Upon auto-approving the elicitation, the +/// command should be run privileged outside the sandbox. +#[tokio::test(flavor = "current_thread")] +async fn accept_elicitation_for_prompt_rule() -> Result<()> { + // Configure a stdio transport that will launch the MCP server using + // $CODEX_HOME with an execpolicy that prompts for `git init` commands. + let codex_home = TempDir::new()?; + write_default_execpolicy( + r#" +# Create a rule with `decision = "prompt"` to exercise the elicitation flow. +prefix_rule( + pattern = ["git", "init"], + decision = "prompt", + match = [ + "git init ." + ], +) +"#, + codex_home.as_ref(), + ) + .await?; + let dotslash_cache_temp_dir = TempDir::new()?; + let dotslash_cache = dotslash_cache_temp_dir.path(); + let transport = create_transport(codex_home.as_ref(), dotslash_cache).await?; + + // Create an MCP client that approves expected elicitation messages. + let project_root = TempDir::new()?; + let git = which::which("git")?; + let project_root_path = project_root.path().canonicalize().unwrap(); + let expected_elicitation_message = format!( + "Allow agent to run `{} init .` in `{}`?", + git.display(), + project_root_path.display() + ); + let elicitation_requests: Arc>> = Default::default(); + let client = InteractiveClient { + elicitations_to_accept: hashset! { expected_elicitation_message.clone() }, + elicitation_requests: elicitation_requests.clone(), + }; + + // Start the MCP server. + let service: rmcp::service::RunningService = + client.serve(transport).await?; + + // Notify the MCP server about the current sandbox state before making any + // `shell` tool calls. + let linux_sandbox_exe_folder = TempDir::new()?; + let codex_linux_sandbox_exe = if cfg!(target_os = "linux") { + let codex_linux_sandbox_exe = linux_sandbox_exe_folder.path().join("codex-linux-sandbox"); + let codex_cli = ensure_codex_cli()?; + symlink(&codex_cli, &codex_linux_sandbox_exe)?; + Some(codex_linux_sandbox_exe) + } else { + None + }; + notify_readable_sandbox(&project_root_path, codex_linux_sandbox_exe, &service).await?; + + // TODO(mbolin): Remove this hack to remove flakiness when possible. + // As noted in the commentary on https://github.com/openai/codex/pull/7832, + // an rmcp server does not process messages serially: it takes messages off + // the queue and immediately dispatches them to handlers, which may complete + // out of order. The proper fix is to replace our custom notification with a + // custom request where we wait for the response before proceeding. However, + // rmcp does not currently support custom requests, so as a temporary + // workaround we just wait a bit to increase the probability the server has + // processed the notification. Assuming we can upstream rmcp support for + // custom requests, we will remove this once the functionality is available. + tokio::time::sleep(Duration::from_secs(4)).await; + + // Call the shell tool and verify that an elicitation was created and + // auto-approved. + let CallToolResult { + content, is_error, .. + } = service + .call_tool(CallToolRequestParam { + name: Cow::Borrowed("shell"), + arguments: Some(object(json!( + { + "command": "git init .", + "workdir": project_root_path.to_string_lossy(), + } + ))), + }) + .await?; + let tool_call_content = content + .first() + .expect("expected non-empty content") + .as_text() + .expect("expected text content"); + let ExecResult { + exit_code, output, .. + } = serde_json::from_str::(&tool_call_content.text)?; + let git_init_succeeded = format!( + "Initialized empty Git repository in {}/.git/\n", + project_root_path.display() + ); + // Normally, this would be an exact match, but it might include extra output + // if `git config set advice.defaultBranchName false` has not been set. + assert!( + output.contains(&git_init_succeeded), + "expected output `{output}` to contain `{git_init_succeeded}`" + ); + assert_eq!(exit_code, 0, "command should succeed"); + assert_eq!(is_error, Some(false), "command should succeed"); + assert!( + project_root_path.join(".git").is_dir(), + "git repo should exist" + ); + + let elicitation_messages = elicitation_requests + .lock() + .unwrap() + .iter() + .map(|r| r.message.clone()) + .collect::>(); + assert_eq!(vec![expected_elicitation_message], elicitation_messages); + + Ok(()) +} + +fn ensure_codex_cli() -> Result { + let codex_cli = PathBuf::from( + assert_cmd::Command::cargo_bin("codex")? + .get_program() + .to_os_string(), + ); + + let metadata = codex_cli.metadata().with_context(|| { + format!( + "failed to read metadata for codex binary at {}", + codex_cli.display() + ) + })?; + ensure!( + metadata.is_file(), + "expected codex binary at {} to be a file; run `cargo build -p codex-cli --bin codex` before this test", + codex_cli.display() + ); + + let mode = metadata.permissions().mode(); + ensure!( + mode & 0o111 != 0, + "codex binary at {} is not executable (mode {mode:o}); run `cargo build -p codex-cli --bin codex` before this test", + codex_cli.display() + ); + + Ok(codex_cli) +} diff --git a/codex-rs/exec-server/tests/suite/bash b/codex-rs/exec-server/tests/suite/bash new file mode 100755 index 00000000000..5f5d1e55939 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/bash @@ -0,0 +1,75 @@ +#!/usr/bin/env dotslash + +// This is an instance of the fork of Bash that we bundle with +// https://www.npmjs.com/package/@openai/codex-shell-tool-mcp. +// Fetching the prebuilt version via DotSlash makes it easier to write +// integration tests for the MCP server. +// +// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for +// multiple platforms, but we could save a bit of space by making arch-specific +// artifacts available in the GitHub releases and referencing those here. +{ + "name": "codex-bash", + "platforms": { + // macOS 13 builds (and therefore x86_64) were dropped in + // https://github.com/openai/codex/pull/7295, so we only provide an + // Apple Silicon build for now. + "macos-aarch64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/aarch64-apple-darwin/bash/macos-15/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + // Note the `musl` parts of the Linux paths are misleading: the Bash + // binaries are actually linked against `glibc`, but the + // `codex-execve-wrapper` that invokes them is linked against `musl`. + "linux-x86_64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/x86_64-unknown-linux-musl/bash/ubuntu-24.04/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + "linux-aarch64": { + "size": 37003612, + "hash": "blake3", + "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", + "format": "tar.gz", + "path": "package/vendor/aarch64-unknown-linux-musl/bash/ubuntu-24.04/bash", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.65.0", + "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" + } + ] + }, + } +} diff --git a/codex-rs/exec-server/tests/suite/list_tools.rs b/codex-rs/exec-server/tests/suite/list_tools.rs new file mode 100644 index 00000000000..2f3d412df7d --- /dev/null +++ b/codex-rs/exec-server/tests/suite/list_tools.rs @@ -0,0 +1,78 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::borrow::Cow; +use std::fs; +use std::sync::Arc; + +use anyhow::Result; +use exec_server_test_support::create_transport; +use pretty_assertions::assert_eq; +use rmcp::ServiceExt; +use rmcp::model::Tool; +use rmcp::model::object; +use serde_json::json; +use tempfile::TempDir; + +/// Verify the list_tools call to the MCP server returns the expected response. +#[tokio::test(flavor = "current_thread")] +async fn list_tools() -> Result<()> { + let codex_home = TempDir::new()?; + let policy_dir = codex_home.path().join("policy"); + fs::create_dir_all(&policy_dir)?; + fs::write( + policy_dir.join("default.codexpolicy"), + r#"prefix_rule(pattern=["ls"], decision="prompt")"#, + )?; + let dotslash_cache_temp_dir = TempDir::new()?; + let dotslash_cache = dotslash_cache_temp_dir.path(); + let transport = create_transport(codex_home.path(), dotslash_cache).await?; + + let service = ().serve(transport).await?; + let tools = service.list_tools(Default::default()).await?.tools; + assert_eq!( + vec![Tool { + name: Cow::Borrowed("shell"), + title: None, + description: Some(Cow::Borrowed( + "Runs a shell command and returns its output. You MUST provide the workdir as an absolute path." + )), + input_schema: Arc::new(object(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "command": { + "description": "The bash string to execute.", + "type": "string", + }, + "login": { + "description": "Launch Bash with -lc instead of -c: defaults to true.", + "nullable": true, + "type": "boolean", + }, + "timeout_ms": { + "description": "The timeout for the command in milliseconds.", + "format": "uint64", + "minimum": 0, + "nullable": true, + "type": "integer", + }, + "workdir": { + "description": "The working directory to execute the command in. Must be an absolute path.", + "type": "string", + }, + }, + "required": [ + "command", + "workdir", + ], + "title": "ExecParams", + "type": "object", + }))), + output_schema: None, + annotations: None, + icons: None, + meta: None + }], + tools + ); + + Ok(()) +} diff --git a/codex-rs/exec-server/tests/suite/mod.rs b/codex-rs/exec-server/tests/suite/mod.rs new file mode 100644 index 00000000000..397a4a6f2bf --- /dev/null +++ b/codex-rs/exec-server/tests/suite/mod.rs @@ -0,0 +1,4 @@ +#[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] +mod accept_elicitation; +#[cfg(any(all(target_os = "macos", target_arch = "aarch64"), target_os = "linux"))] +mod list_tools; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 64a5358f353..1da0796a752 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -140,7 +140,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { VERSION ); - let mut entries = create_config_summary_entries(config); + let mut entries = + create_config_summary_entries(config, session_configured_event.model.as_str()); entries.push(( "session id", session_configured_event.session_id.to_string(), @@ -566,6 +567,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::WebSearchBegin(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 23dff015eb3..03c51662b14 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -72,6 +73,7 @@ pub struct EventProcessorWithJsonOutput { struct RunningCommand { command: String, item_id: String, + aggregated_output: String, } #[derive(Debug, Clone)] @@ -109,6 +111,10 @@ impl EventProcessorWithJsonOutput { EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), + EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), + EventMsg::ExecCommandOutputDelta(ev) => { + self.handle_output_chunk(&ev.call_id, &ev.chunk) + } EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), @@ -172,6 +178,16 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { + //TODO see how we want to process them + vec![] + } + + fn handle_terminal_interaction(&mut self, _ev: &TerminalInteractionEvent) -> Vec { + //TODO see how we want to process them + vec![] + } + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), @@ -214,6 +230,7 @@ impl EventProcessorWithJsonOutput { RunningCommand { command: command_string.clone(), item_id: item_id.clone(), + aggregated_output: String::new(), }, ); @@ -366,7 +383,11 @@ impl EventProcessorWithJsonOutput { } fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) + let Some(RunningCommand { + command, + item_id, + aggregated_output, + }) = self.running_commands.remove(&ev.call_id) else { warn!( call_id = ev.call_id, @@ -379,12 +400,17 @@ impl EventProcessorWithJsonOutput { } else { CommandExecutionStatus::Failed }; + let aggregated_output = if ev.aggregated_output.is_empty() { + aggregated_output + } else { + ev.aggregated_output.clone() + }; let item = ThreadItem { id: item_id, details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, - aggregated_output: ev.aggregated_output.clone(), + aggregated_output, exit_code: Some(ev.exit_code), status, }), @@ -455,6 +481,21 @@ impl EventProcessorWithJsonOutput { items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } + if !self.running_commands.is_empty() { + for (_, running) in self.running_commands.drain() { + let item = ThreadItem { + id: running.item_id, + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: running.command, + aggregated_output: running.aggregated_output, + exit_code: None, + status: CommandExecutionStatus::Completed, + }), + }; + items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + } + if let Some(error) = self.last_critical_error.take() { items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); } else { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0cf5aaf7882..7d7d4c301fb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -200,7 +200,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: add_dir, }; @@ -264,7 +263,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_cwd = config.cwd.to_path_buf(); let default_approval_policy = config.approval_policy; let default_sandbox_policy = config.sandbox_policy.clone(); - let default_model = config.model.clone(); let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; @@ -279,6 +277,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config.cli_auth_credentials_store_mode, ); let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); + let default_model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 7b2d3c18ba8..2291698d665 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -48,6 +48,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::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -83,6 +85,7 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path, }), ); @@ -699,6 +702,93 @@ fn exec_command_end_success_produces_completed_command_item() { ); } +#[test] +fn command_execution_output_delta_updates_item_progress() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo delta".to_string(), + ]; + let cwd = std::env::current_dir().unwrap(); + let parsed_cmd = Vec::new(); + + let begin = event( + "d1", + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + ); + let out_begin = ep.collect_thread_events(&begin); + assert_eq!( + out_begin, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })] + ); + + let delta = event( + "d2", + EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: "delta-1".to_string(), + stream: ExecOutputStream::Stdout, + chunk: b"partial output\n".to_vec(), + }), + ); + let out_delta = ep.collect_thread_events(&delta); + assert_eq!(out_delta, Vec::::new()); + + let end = event( + "d3", + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_millis(3), + formatted_output: String::new(), + }), + ); + let out_end = ep.collect_thread_events(&end); + assert_eq!( + out_end, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + })] + ); +} + #[test] fn exec_command_end_failure_produces_failed_command_item() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 5bc96130dda..119d859b26f 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -102,12 +102,10 @@ fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), deny_syscall(libc::SYS_getsockname); deny_syscall(libc::SYS_shutdown); deny_syscall(libc::SYS_sendto); - deny_syscall(libc::SYS_sendmsg); deny_syscall(libc::SYS_sendmmsg); // NOTE: allowing recvfrom allows some tools like: `cargo clippy` to run // with their socketpair + child processes for sub-proc management // deny_syscall(libc::SYS_recvfrom); - deny_syscall(libc::SYS_recvmsg); deny_syscall(libc::SYS_recvmmsg); deny_syscall(libc::SYS_getsockopt); deny_syscall(libc::SYS_setsockopt); diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index e145aa2f739..791f9b1ea7e 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -6,6 +6,7 @@ use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -41,7 +42,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { cwd, expiration: timeout_ms.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -143,7 +144,7 @@ async fn assert_network_blocked(cmd: &[&str]) { // do not stall the suite. expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/lmstudio/src/lib.rs b/codex-rs/lmstudio/src/lib.rs index bb8c8cef6a3..fd4f82a728a 100644 --- a/codex-rs/lmstudio/src/lib.rs +++ b/codex-rs/lmstudio/src/lib.rs @@ -11,7 +11,10 @@ pub const DEFAULT_OSS_MODEL: &str = "openai/gpt-oss-20b"; /// - Ensures a local LM Studio server is reachable. /// - Checks if the model exists locally and downloads it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { - let model: &str = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local LM Studio is reachable. let lmstudio_client = LMStudioClient::try_from_provider(config).await?; diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index a121de7ebd4..d9e7d90ce28 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -141,7 +141,7 @@ fn print_device_code_prompt(code: &str) { println!( "\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\ \nFollow these steps to sign in with ChatGPT using device code authorization:\n\ -\n1. Open this link in your browser\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\ +\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}https://auth.openai.com/codex/device{ANSI_RESET}\n\ \n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\ \n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n", version = env!("CARGO_PKG_VERSION"), diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 4e61bde02b8..feadf0add65 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -169,7 +169,6 @@ impl CodexToolCallParam { include_apply_patch_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index aa895d8dd3d..d39a38cde94 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -179,7 +179,6 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, - risk, proposed_execpolicy_amendment: _, parsed_cmd, }) => { @@ -193,7 +192,6 @@ async fn run_codex_tool_session_inner( event.id.clone(), call_id, parsed_cmd, - risk, ) .await; continue; @@ -282,6 +280,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 033523ac0df..44607b754d7 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; @@ -38,8 +37,6 @@ pub struct ExecApprovalElicitRequestParams { pub codex_command: Vec, pub codex_cwd: PathBuf, pub codex_parsed_cmd: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub codex_risk: Option, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -62,7 +59,6 @@ pub(crate) async fn handle_exec_approval_request( event_id: String, call_id: String, codex_parsed_cmd: Vec, - codex_risk: Option, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -85,7 +81,6 @@ pub(crate) async fn handle_exec_approval_request( codex_command: command, codex_cwd: cwd, codex_parsed_cmd, - codex_risk, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 83ac25fdfd4..3af472ddb9f 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -266,6 +266,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; @@ -305,6 +306,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; let event = Event { diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index f65495c473a..d0a78ae3927 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -200,7 +200,6 @@ fn create_expected_elicitation_request( codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), codex_parsed_cmd, - codex_risk: None, })?), }) } diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 0ebf1662ac2..4ced3b62760 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -19,7 +19,10 @@ pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; /// - Checks if the model exists locally and pulls it if missing. pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { // Only download when the requested model is the default OSS model (or when -m is not provided). - let model = config.model.as_ref(); + let model = match config.model.as_ref() { + Some(model) => model, + None => DEFAULT_OSS_MODEL, + }; // Verify local Ollama is reachable. let ollama_client = crate::OllamaClient::try_from_oss_provider(config).await?; diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index 059e8e38e63..af8b72346d6 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -21,6 +21,7 @@ otel = ["opentelemetry", "opentelemetry_sdk", "opentelemetry-otlp", "tonic"] [dependencies] chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-protocol = { workspace = true } eventsource-stream = { workspace = true } opentelemetry = { workspace = true, features = ["logs"], optional = true } @@ -29,7 +30,7 @@ opentelemetry-otlp = { workspace = true, features = [ "http-proto", "http-json", "logs", - "reqwest", + "reqwest-blocking-client", "reqwest-rustls", "tls", "tls-roots", @@ -40,7 +41,7 @@ opentelemetry_sdk = { workspace = true, features = [ "rt-tokio", ], optional = true } http = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/otel/src/config.rs b/codex-rs/otel/src/config.rs index b6336b3a5c2..652a1c97b2b 100644 --- a/codex-rs/otel/src/config.rs +++ b/codex-rs/otel/src/config.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::path::PathBuf; +use codex_utils_absolute_path::AbsolutePathBuf; + #[derive(Clone, Debug)] pub struct OtelSettings { pub environment: String, @@ -20,9 +22,9 @@ pub enum OtelHttpProtocol { #[derive(Clone, Debug, Default)] pub struct OtelTlsConfig { - pub ca_certificate: Option, - pub client_certificate: Option, - pub client_private_key: Option, + pub ca_certificate: Option, + pub client_certificate: Option, + pub client_private_key: Option, } #[derive(Clone, Debug)] diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index d3536cd8db2..54e3fe3dc18 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -8,7 +8,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SandboxRiskLevel; use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; use eventsource_stream::EventStreamError as StreamError; @@ -374,52 +373,6 @@ impl OtelEventManager { ); } - pub fn sandbox_assessment( - &self, - call_id: &str, - status: &str, - risk_level: Option, - duration: Duration, - ) { - let level = risk_level.map(|level| level.as_str()); - - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - status = %status, - risk_level = level, - duration_ms = %duration.as_millis(), - ); - } - - pub fn sandbox_assessment_latency(&self, call_id: &str, duration: Duration) { - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment_latency", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - duration_ms = %duration.as_millis(), - ); - } - pub async fn log_tool_result( &self, tool_name: &str, diff --git a/codex-rs/otel/src/otel_provider.rs b/codex-rs/otel/src/otel_provider.rs index 8be2431ea94..92b1feaa188 100644 --- a/codex-rs/otel/src/otel_provider.rs +++ b/codex-rs/otel/src/otel_provider.rs @@ -2,6 +2,7 @@ use crate::config::OtelExporter; use crate::config::OtelHttpProtocol; use crate::config::OtelSettings; use crate::config::OtelTlsConfig; +use codex_utils_absolute_path::AbsolutePathBuf; use http::Uri; use opentelemetry::KeyValue; use opentelemetry_otlp::LogExporter; @@ -25,7 +26,6 @@ use std::error::Error; use std::fs; use std::io::ErrorKind; use std::io::{self}; -use std::path::Path; use std::path::PathBuf; use std::time::Duration; use tonic::metadata::MetadataMap; @@ -85,12 +85,7 @@ impl OtelProvider { .assume_http2(true); let tls_config = match tls.as_ref() { - Some(tls) => build_grpc_tls_config( - endpoint, - base_tls_config, - tls, - settings.codex_home.as_path(), - )?, + Some(tls) => build_grpc_tls_config(endpoint, base_tls_config, tls)?, None => base_tls_config, }; @@ -123,7 +118,7 @@ impl OtelProvider { .with_headers(headers.clone()); if let Some(tls) = tls.as_ref() { - let client = build_http_client(tls, settings.codex_home.as_path())?; + let client = build_http_client(tls)?; exporter_builder = exporter_builder.with_http_client(client); } @@ -149,7 +144,6 @@ fn build_grpc_tls_config( endpoint: &str, tls_config: ClientTlsConfig, tls: &OtelTlsConfig, - codex_home: &Path, ) -> Result> { let uri: Uri = endpoint.parse()?; let host = uri.host().ok_or_else(|| { @@ -161,14 +155,14 @@ fn build_grpc_tls_config( let mut config = tls_config.domain_name(host.to_owned()); if let Some(path) = tls.ca_certificate.as_ref() { - let (pem, _) = read_bytes(codex_home, path)?; + let (pem, _) = read_bytes(path)?; config = config.ca_certificate(TonicCertificate::from_pem(pem)); } match (&tls.client_certificate, &tls.client_private_key) { (Some(cert_path), Some(key_path)) => { - let (cert_pem, _) = read_bytes(codex_home, cert_path)?; - let (key_pem, _) = read_bytes(codex_home, key_path)?; + let (cert_pem, _) = read_bytes(cert_path)?; + let (key_pem, _) = read_bytes(key_path)?; config = config.identity(TonicIdentity::from_pem(cert_pem, key_pem)); } (Some(_), None) | (None, Some(_)) => { @@ -182,28 +176,42 @@ fn build_grpc_tls_config( Ok(config) } -fn build_http_client( +/// Build a blocking HTTP client with TLS configuration for the OTLP HTTP exporter. +/// +/// We use `reqwest::blocking::Client` instead of the async client because the +/// `opentelemetry_sdk` `BatchLogProcessor` spawns a dedicated OS thread that uses +/// `futures_executor::block_on()` rather than tokio. When the async reqwest client's +/// timeout calls `tokio::time::sleep()`, it panics with "no reactor running". +fn build_http_client(tls: &OtelTlsConfig) -> Result> { + // Wrap in block_in_place because reqwest::blocking::Client creates its own + // internal tokio runtime, which would panic if built directly from an async context. + tokio::task::block_in_place(|| build_http_client_inner(tls)) +} + +fn build_http_client_inner( tls: &OtelTlsConfig, - codex_home: &Path, -) -> Result> { - let mut builder = - reqwest::Client::builder().timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); +) -> Result> { + let mut builder = reqwest::blocking::Client::builder() + .timeout(resolve_otlp_timeout(OTEL_EXPORTER_OTLP_LOGS_TIMEOUT)); if let Some(path) = tls.ca_certificate.as_ref() { - let (pem, location) = read_bytes(codex_home, path)?; + let (pem, location) = read_bytes(path)?; let certificate = ReqwestCertificate::from_pem(pem.as_slice()).map_err(|error| { config_error(format!( "failed to parse certificate {}: {error}", location.display() )) })?; - builder = builder.add_root_certificate(certificate); + // Disable built-in root certificates and use only our custom CA + builder = builder + .tls_built_in_root_certs(false) + .add_root_certificate(certificate); } match (&tls.client_certificate, &tls.client_private_key) { (Some(cert_path), Some(key_path)) => { - let (mut cert_pem, cert_location) = read_bytes(codex_home, cert_path)?; - let (key_pem, key_location) = read_bytes(codex_home, key_path)?; + let (mut cert_pem, cert_location) = read_bytes(cert_path)?; + let (key_pem, key_location) = read_bytes(key_path)?; cert_pem.extend_from_slice(key_pem.as_slice()); let identity = ReqwestIdentity::from_pem(cert_pem.as_slice()).map_err(|error| { config_error(format!( @@ -212,7 +220,7 @@ fn build_http_client( key_location.display() )) })?; - builder = builder.identity(identity); + builder = builder.identity(identity).https_only(true); } (Some(_), None) | (None, Some(_)) => { return Err(config_error( @@ -246,25 +254,16 @@ fn read_timeout_env(var: &str) -> Option { Some(Duration::from_millis(parsed as u64)) } -fn read_bytes(base: &Path, provided: &PathBuf) -> Result<(Vec, PathBuf), Box> { - let resolved = resolve_config_path(base, provided); - match fs::read(&resolved) { - Ok(bytes) => Ok((bytes, resolved)), +fn read_bytes(path: &AbsolutePathBuf) -> Result<(Vec, PathBuf), Box> { + match fs::read(path) { + Ok(bytes) => Ok((bytes, path.to_path_buf())), Err(error) => Err(Box::new(io::Error::new( error.kind(), - format!("failed to read {}: {error}", resolved.display()), + format!("failed to read {}: {error}", path.display()), ))), } } -fn resolve_config_path(base: &Path, provided: &PathBuf) -> PathBuf { - if provided.is_absolute() { - provided.clone() - } else { - base.join(provided) - } -} - fn config_error(message: impl Into) -> Box { Box::new(io::Error::new(ErrorKind::InvalidData, message.into())) } diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml index 2a867572df5..7cc88ed608c 100644 --- a/codex-rs/process-hardening/Cargo.toml +++ b/codex-rs/process-hardening/Cargo.toml @@ -13,3 +13,6 @@ workspace = true [dependencies] libc = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/process-hardening/src/lib.rs b/codex-rs/process-hardening/src/lib.rs index 772647671f7..fb6145f1763 100644 --- a/codex-rs/process-hardening/src/lib.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(unix)] +use std::ffi::OsString; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; + /// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform /// various process hardening steps, such as /// - disabling core dumps @@ -51,15 +57,7 @@ pub(crate) fn pre_main_hardening_linux() { // Official Codex releases are MUSL-linked, which means that variables such // as LD_PRELOAD are ignored anyway, but just to be sure, clear them here. - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { @@ -73,15 +71,7 @@ pub(crate) fn pre_main_hardening_bsd() { // FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars set_core_file_size_limit_to_zero(); - let ld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("LD_") { - Some(key) - } else { - None - } - }) - .collect(); + let ld_keys = env_keys_with_prefix(std::env::vars_os(), b"LD_"); for key in ld_keys { unsafe { std::env::remove_var(key); @@ -106,15 +96,7 @@ pub(crate) fn pre_main_hardening_macos() { // Remove all DYLD_ environment variables, which can be used to subvert // library loading. - let dyld_keys: Vec = std::env::vars() - .filter_map(|(key, _)| { - if key.starts_with("DYLD_") { - Some(key) - } else { - None - } - }) - .collect(); + let dyld_keys = env_keys_with_prefix(std::env::vars_os(), b"DYLD_"); for key in dyld_keys { unsafe { @@ -144,3 +126,65 @@ fn set_core_file_size_limit_to_zero() { pub(crate) fn pre_main_hardening_windows() { // TODO(mbolin): Perform the appropriate configuration for Windows. } + +#[cfg(unix)] +fn env_keys_with_prefix(vars: I, prefix: &[u8]) -> Vec +where + I: IntoIterator, +{ + vars.into_iter() + .filter_map(|(key, _)| { + key.as_os_str() + .as_bytes() + .starts_with(prefix) + .then_some(key) + }) + .collect() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStringExt; + + #[test] + fn env_keys_with_prefix_handles_non_utf8_entries() { + // RÖDBURK + let non_utf8_key1 = OsStr::from_bytes(b"R\xD6DBURK").to_os_string(); + assert!(non_utf8_key1.clone().into_string().is_err()); + let non_utf8_key2 = OsString::from_vec(vec![b'L', b'D', b'_', 0xF0]); + assert!(non_utf8_key2.clone().into_string().is_err()); + + let non_utf8_value = OsString::from_vec(vec![0xF0, 0x9F, 0x92, 0xA9]); + + let keys = env_keys_with_prefix( + vec![ + (non_utf8_key1, non_utf8_value.clone()), + (non_utf8_key2.clone(), non_utf8_value), + ], + b"LD_", + ); + assert_eq!( + keys, + vec![non_utf8_key2], + "non-UTF-8 env entries with LD_ prefix should be retained" + ); + } + + #[test] + fn env_keys_with_prefix_filters_only_matching_keys() { + let ld_test_var = OsStr::from_bytes(b"LD_TEST"); + let vars = vec![ + (OsString::from("PATH"), OsString::from("/usr/bin")), + (ld_test_var.to_os_string(), OsString::from("1")), + (OsString::from("DYLD_FOO"), OsString::from("bar")), + ]; + + let keys = env_keys_with_prefix(vars, b"LD_"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].as_os_str(), ld_test_var); + } +} diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 08f83753570..46f030c60a6 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] codex-git = { workspace = true } -base64 = { workspace = true } codex-utils-image = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index c892b6ec991..78050dfa860 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -9,14 +9,6 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum SandboxRiskLevel { - Low, - Medium, - High, -} - /// Proposed execpolicy change to allow commands starting with this prefix. /// /// The `command` tokens form the prefix that would be added as an execpolicy @@ -45,22 +37,6 @@ impl From> for ExecPolicyAmendment { } } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: SandboxRiskLevel, -} - -impl SandboxRiskLevel { - pub fn as_str(&self) -> &'static str { - match self { - Self::Low => "low", - Self::Medium => "medium", - Self::High => "high", - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. @@ -76,9 +52,6 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - #[serde(skip_serializing_if = "Option::is_none")] - pub risk: Option, /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index daf98152d51..5c609c3c463 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; -use base64::Engine; use codex_utils_image::load_and_resize_to_fit; use mcp_types::CallToolResult; use mcp_types::ContentBlock; @@ -15,6 +14,25 @@ use codex_git::GhostCommit; use codex_utils_image::error::ImageProcessingError; use schemars::JsonSchema; +/// Controls whether a command should use the session sandbox or bypass it. +#[derive( + Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, +)] +#[serde(rename_all = "snake_case")] +pub enum SandboxPermissions { + /// Run with the configured sandbox + #[default] + UseDefault, + /// Request to run outside the sandbox + RequireEscalated, +} + +impl SandboxPermissions { + pub fn requires_escalated_permissions(self) -> bool { + matches!(self, SandboxPermissions::RequireEscalated) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { @@ -175,6 +193,16 @@ fn invalid_image_error_placeholder( } } +fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem { + ContentItem::InputText { + text: format!( + "Codex cannot attach image at `{}`: unsupported image format `{}`.", + path.display(), + mime + ), + } +} + impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { @@ -272,53 +300,37 @@ impl From> for ResponseInputItem { role: "user".to_string(), content: items .into_iter() - .map(|c| match c { - UserInput::Text { text } => ContentItem::InputText { text }, - UserInput::Image { image_url } => ContentItem::InputImage { image_url }, + .filter_map(|c| match c { + UserInput::Text { text } => Some(ContentItem::InputText { text }), + UserInput::Image { image_url } => Some(ContentItem::InputImage { image_url }), UserInput::LocalImage { path } => match load_and_resize_to_fit(&path) { - Ok(image) => ContentItem::InputImage { + Ok(image) => Some(ContentItem::InputImage { image_url: image.into_data_url(), - }, + }), Err(err) => { if matches!(&err, ImageProcessingError::Read { .. }) { - local_image_error_placeholder(&path, &err) + Some(local_image_error_placeholder(&path, &err)) } else if err.is_invalid_image() { - invalid_image_error_placeholder(&path, &err) + Some(invalid_image_error_placeholder(&path, &err)) } else { - match std::fs::read(&path) { - Ok(bytes) => { - let Some(mime_guess) = mime_guess::from_path(&path).first() - else { - return local_image_error_placeholder( - &path, - "unsupported MIME type (unknown)", - ); - }; - let mime = mime_guess.essence_str().to_owned(); - if !mime.starts_with("image/") { - return local_image_error_placeholder( - &path, - format!("unsupported MIME type `{mime}`"), - ); - } - let encoded = - base64::engine::general_purpose::STANDARD.encode(bytes); - ContentItem::InputImage { - image_url: format!("data:{mime};base64,{encoded}"), - } - } - Err(read_err) => { - tracing::warn!( - "Skipping image {} – could not read file: {}", - path.display(), - read_err - ); - local_image_error_placeholder(&path, &read_err) - } + let Some(mime_guess) = mime_guess::from_path(&path).first() else { + return Some(local_image_error_placeholder( + &path, + "unsupported MIME type (unknown)", + )); + }; + let mime = mime_guess.essence_str().to_owned(); + if !mime.starts_with("image/") { + return Some(local_image_error_placeholder( + &path, + format!("unsupported MIME type `{mime}`"), + )); } + Some(unsupported_image_error_placeholder(&path, &mime)) } } }, + UserInput::Skill { .. } => None, // Skill bodies are injected later in core }) .collect::>(), } @@ -335,8 +347,9 @@ pub struct ShellToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -348,11 +361,15 @@ pub struct ShellCommandToolCallParams { pub command: String, pub workdir: Option, + /// Whether to run the shell with login shell semantics + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -747,7 +764,7 @@ mod tests { command: vec!["ls".to_string(), "-l".to_string()], workdir: Some("/tmp".to_string()), timeout_ms: Some(1000), - with_escalated_permissions: None, + sandbox_permissions: None, justification: None, }, params @@ -820,4 +837,36 @@ mod tests { Ok(()) } + + #[test] + fn local_image_unsupported_image_format_adds_placeholder() -> Result<()> { + let dir = tempdir()?; + let svg_path = dir.path().join("example.svg"); + std::fs::write( + &svg_path, + br#" +"#, + )?; + + let item = ResponseInputItem::from(vec![UserInput::LocalImage { + path: svg_path.clone(), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert_eq!(content.len(), 1); + let expected = format!( + "Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.", + svg_path.display() + ); + match &content[0] { + ContentItem::InputText { text } => assert_eq!(text, &expected), + other => panic!("expected placeholder text but found {other:?}"), + } + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } } diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 02d50627ca7..c5500f1cc5b 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; use ts_rs::TS; @@ -36,7 +37,7 @@ pub enum ReasoningEffort { } /// A reasoning effort option that can be surfaced for a model. -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] pub struct ReasoningEffortPreset { /// Effort level that the model supports. pub effort: ReasoningEffort, @@ -123,7 +124,7 @@ pub struct ModelInfo { #[serde(default)] pub description: Option, pub default_reasoning_level: ReasoningEffort, - pub supported_reasoning_levels: Vec, + pub supported_reasoning_levels: Vec, pub shell_type: ConfigShellToolType, #[serde(default = "default_visibility")] pub visibility: ModelVisibility, @@ -132,12 +133,18 @@ pub struct ModelInfo { pub supported_in_api: bool, #[serde(default)] pub priority: i32, + #[serde(default)] + pub upgrade: Option, + #[serde(default)] + pub base_instructions: Option, } /// Response wrapper for `/models`. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema, Default)] pub struct ModelsResponse { pub models: Vec, + #[serde(default)] + pub etag: String, } fn default_visibility() -> ModelVisibility { @@ -149,22 +156,57 @@ impl From for ModelPreset { fn from(info: ModelInfo) -> Self { ModelPreset { id: info.slug.clone(), - model: info.slug, + model: info.slug.clone(), display_name: info.display_name, description: info.description.unwrap_or_default(), default_reasoning_effort: info.default_reasoning_level, - supported_reasoning_efforts: info - .supported_reasoning_levels - .into_iter() - .map(|level| ReasoningEffortPreset { - effort: level, - // todo: add description for each reasoning effort - description: level.to_string(), - }) - .collect(), + supported_reasoning_efforts: info.supported_reasoning_levels.clone(), is_default: false, // default is the highest priority available model - upgrade: None, // no upgrade available (todo: think about it) + upgrade: info.upgrade.as_ref().map(|upgrade_slug| ModelUpgrade { + id: upgrade_slug.clone(), + reasoning_effort_mapping: reasoning_effort_mapping_from_presets( + &info.supported_reasoning_levels, + ), + migration_config_key: info.slug.clone(), + }), show_in_picker: info.visibility == ModelVisibility::List, } } } + +fn reasoning_effort_mapping_from_presets( + presets: &[ReasoningEffortPreset], +) -> Option> { + if presets.is_empty() { + return None; + } + + // Map every canonical effort to the closest supported effort for the new model. + let supported: Vec = presets.iter().map(|p| p.effort).collect(); + let mut map = HashMap::new(); + for effort in ReasoningEffort::iter() { + let nearest = nearest_effort(effort, &supported); + map.insert(effort, nearest); + } + Some(map) +} + +fn effort_rank(effort: ReasoningEffort) -> i32 { + match effort { + ReasoningEffort::None => 0, + ReasoningEffort::Minimal => 1, + ReasoningEffort::Low => 2, + ReasoningEffort::Medium => 3, + ReasoningEffort::High => 4, + ReasoningEffort::XHigh => 5, + } +} + +fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> ReasoningEffort { + let target_rank = effort_rank(target); + supported + .iter() + .copied() + .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) + .unwrap_or(target) +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 225a622dcce..73e2c9c8771 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -40,8 +40,6 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; -pub use crate::approvals::SandboxCommandAssessment; -pub use crate::approvals::SandboxRiskLevel; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. @@ -518,6 +516,9 @@ pub enum EventMsg { /// Incremental chunk of output from a running command. ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), + /// Terminal interaction for an in-progress command (stdin sent and stdout observed). + TerminalInteraction(TerminalInteractionEvent), + ExecCommandEnd(ExecCommandEndEvent), /// Notification that the agent attached a local image via the view_image tool. @@ -1348,7 +1349,7 @@ pub struct ReviewLineRange { pub end: u32, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Display, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ExecCommandSource { Agent, @@ -1455,6 +1456,17 @@ pub struct ExecCommandOutputDeltaEvent { pub chunk: Vec, } +#[serde_as] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TerminalInteractionEvent { + /// Identifier for the ExecCommandBegin that produced this chunk. + pub call_id: String, + /// Process id associated with the running command. + pub process_id: String, + /// Stdin sent to the running session. + pub stdin: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, @@ -1612,6 +1624,25 @@ pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillInfo { + pub name: String, + pub description: String, + pub path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)] +pub struct SkillLoadOutcomeInfo { + pub skills: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. @@ -1647,6 +1678,9 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skill_load_outcome: Option, + pub rollout_path: PathBuf, } @@ -1774,6 +1808,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 881b9965145..26773e1a1a8 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -21,4 +21,10 @@ pub enum UserInput { LocalImage { path: std::path::PathBuf, }, + + /// Skill selected by the user (name + path to SKILL.md). + Skill { + name: String, + path: std::path::PathBuf, + }, } diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index ac617f3d29c..954898cea49 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -16,7 +16,9 @@ pub use oauth::WrappedOAuthTokenResponse; pub use oauth::delete_oauth_tokens; pub(crate) use oauth::load_oauth_tokens; pub use oauth::save_oauth_tokens; +pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; +pub use perform_oauth_login::perform_oauth_login_return_url; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index d8ffdd3949a..9815a3a22d6 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -22,6 +22,11 @@ use crate::save_oauth_tokens; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; +struct OauthHeaders { + http_headers: Option>, + env_http_headers: Option>, +} + struct CallbackServerGuard { server: Arc, } @@ -40,70 +45,52 @@ pub async fn perform_oauth_login( env_http_headers: Option>, scopes: &[String], ) -> Result<()> { - let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); - let guard = CallbackServerGuard { - server: Arc::clone(&server), + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + true, + None, + ) + .await? + .finish() + .await +} - let redirect_uri = match server.server_addr() { - tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { - format!("http://{}:{}/callback", addr.ip(), addr.port()) - } - tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { - format!("http://[{}]:{}/callback", addr.ip(), addr.port()) - } - #[cfg(not(target_os = "windows"))] - _ => return Err(anyhow!("unable to determine callback address")), +pub async fn perform_oauth_login_return_url( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + timeout_secs: Option, +) -> Result { + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + let flow = OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + false, + timeout_secs, + ) + .await?; - let (tx, rx) = oneshot::channel(); - spawn_callback_server(server, tx); - - let default_headers = build_default_headers(http_headers, env_http_headers)?; - let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; - - let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; - let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); - oauth_state - .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) - .await?; - let auth_url = oauth_state.get_authorization_url().await?; - - println!("Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n"); - - if webbrowser::open(&auth_url).is_err() { - println!("(Browser launch failed; please copy the URL above manually.)"); - } - - let (code, csrf_state) = timeout(Duration::from_secs(300), rx) - .await - .context("timed out waiting for OAuth callback")? - .context("OAuth callback was cancelled")?; - - oauth_state - .handle_callback(&code, &csrf_state) - .await - .context("failed to handle OAuth callback")?; - - let (client_id, credentials_opt) = oauth_state - .get_credentials() - .await - .context("failed to retrieve OAuth credentials")?; - let credentials = - credentials_opt.ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; - - let expires_at = compute_expires_at_millis(&credentials); - let stored = StoredOAuthTokens { - server_name: server_name.to_string(), - url: server_url.to_string(), - client_id, - token_response: WrappedOAuthTokenResponse(credentials), - expires_at, - }; - save_oauth_tokens(server_name, &stored, store_mode)?; + let authorization_url = flow.authorization_url(); + let completion = flow.spawn(); - drop(guard); - Ok(()) + Ok(OauthLoginHandle::new(authorization_url, completion)) } fn spawn_callback_server(server: Arc, tx: oneshot::Sender<(String, String)>) { @@ -160,3 +147,181 @@ fn parse_oauth_callback(path: &str) -> Option { state: state?, }) } + +pub struct OauthLoginHandle { + authorization_url: String, + completion: oneshot::Receiver>, +} + +impl OauthLoginHandle { + fn new(authorization_url: String, completion: oneshot::Receiver>) -> Self { + Self { + authorization_url, + completion, + } + } + + pub fn authorization_url(&self) -> &str { + &self.authorization_url + } + + pub fn into_parts(self) -> (String, oneshot::Receiver>) { + (self.authorization_url, self.completion) + } + + pub async fn wait(self) -> Result<()> { + self.completion + .await + .map_err(|err| anyhow!("OAuth login task was cancelled: {err}"))? + } +} + +struct OauthLoginFlow { + auth_url: String, + oauth_state: OAuthState, + rx: oneshot::Receiver<(String, String)>, + guard: CallbackServerGuard, + server_name: String, + server_url: String, + store_mode: OAuthCredentialsStoreMode, + launch_browser: bool, + timeout: Duration, +} + +impl OauthLoginFlow { + async fn new( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + headers: OauthHeaders, + scopes: &[String], + launch_browser: bool, + timeout_secs: Option, + ) -> Result { + const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300; + + let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); + let guard = CallbackServerGuard { + server: Arc::clone(&server), + }; + + let redirect_uri = match server.server_addr() { + tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://{ip}:{port}/callback") + } + tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://[{ip}]:{port}/callback") + } + #[cfg(not(target_os = "windows"))] + _ => return Err(anyhow!("unable to determine callback address")), + }; + + let (tx, rx) = oneshot::channel(); + spawn_callback_server(server, tx); + + let OauthHeaders { + http_headers, + env_http_headers, + } = headers; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + oauth_state + .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) + .await?; + let auth_url = oauth_state.get_authorization_url().await?; + let timeout_secs = timeout_secs.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS).max(1); + let timeout = Duration::from_secs(timeout_secs as u64); + + Ok(Self { + auth_url, + oauth_state, + rx, + guard, + server_name: server_name.to_string(), + server_url: server_url.to_string(), + store_mode, + launch_browser, + timeout, + }) + } + + fn authorization_url(&self) -> String { + self.auth_url.clone() + } + + async fn finish(mut self) -> Result<()> { + if self.launch_browser { + let server_name = &self.server_name; + let auth_url = &self.auth_url; + println!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + + if webbrowser::open(auth_url).is_err() { + println!("(Browser launch failed; please copy the URL above manually.)"); + } + } + + let result = async { + let (code, csrf_state) = timeout(self.timeout, &mut self.rx) + .await + .context("timed out waiting for OAuth callback")? + .context("OAuth callback was cancelled")?; + + self.oauth_state + .handle_callback(&code, &csrf_state) + .await + .context("failed to handle OAuth callback")?; + + let (client_id, credentials_opt) = self + .oauth_state + .get_credentials() + .await + .context("failed to retrieve OAuth credentials")?; + let credentials = credentials_opt + .ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; + + let expires_at = compute_expires_at_millis(&credentials); + let stored = StoredOAuthTokens { + server_name: self.server_name.clone(), + url: self.server_url.clone(), + client_id, + token_response: WrappedOAuthTokenResponse(credentials), + expires_at, + }; + save_oauth_tokens(&self.server_name, &stored, self.store_mode)?; + + Ok(()) + } + .await; + + drop(self.guard); + result + } + + fn spawn(self) -> oneshot::Receiver> { + let server_name_for_logging = self.server_name.clone(); + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let result = self.finish().await; + + if let Err(err) = &result { + eprintln!( + "Failed to complete OAuth login for '{server_name_for_logging}': {err:#}" + ); + } + + let _ = tx.send(result); + }); + + rx + } +} diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index e7b8972db5c..fffd987bc15 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -59,6 +59,8 @@ def parse_args(argv: list[str]) -> argparse.Namespace: def main(argv: list[str]) -> int: args = parse_args(argv) + ensure_release_not_in_progress() + # Strip the leading "v" if present. promote_alpha = args.promote_alpha if promote_alpha and promote_alpha.startswith("v"): @@ -144,6 +146,36 @@ def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = Non raise ReleaseError("Failed to parse response from gh api.") from error +def ensure_release_not_in_progress() -> None: + """Fail fast if a release workflow is already running or queued.""" + + statuses = ("in_progress", "queued") + runs: list[dict] = [] + for status in statuses: + response = run_gh_api( + f"/repos/{REPO}/actions/workflows/rust-release.yml/runs?per_page=50&status={status}" + ) + runs.extend(response.get("workflow_runs", [])) + + active_runs = [run for run in runs if run.get("status") in statuses] + if not active_runs: + return + + seen_ids: set[int] = set() + urls: list[str] = [] + for run in active_runs: + run_id = run.get("id") + if run_id in seen_ids: + continue + seen_ids.add(run_id) + urls.append(run.get("html_url", str(run_id))) + + raise ReleaseError( + "Release workflow already running or queued; wait or cancel it before publishing: " + + ", ".join(urls) + ) + + def get_branch_head() -> str: response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}") try: diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 248205c4278..4e5fad06b4b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -105,6 +105,7 @@ arboard = { workspace = true } [dev-dependencies] +codex-core = { workspace = true, features = ["test-support"] } assert_matches = { workspace = true } chrono = { workspace = true, features = ["serde"] } insta = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 28535e53662..a12393c91a1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,6 +25,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; +#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; @@ -33,9 +34,9 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; +use codex_core::protocol::SkillLoadOutcomeInfo; use codex_core::protocol::TokenUsage; -use codex_core::skills::load_skills; -use codex_core::skills::model::SkillMetadata; +use codex_core::skills::SkillError; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -88,6 +89,17 @@ fn session_summary( }) } +fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { + outcome + .errors + .iter() + .map(|err| SkillError { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -123,14 +135,15 @@ fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Optio async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, + model: &str, app_event_tx: &AppEventSender, auth_mode: Option, models_manager: Arc, ) -> Option { - let available_models = models_manager.available_models.read().await.clone(); + let available_models = models_manager.list_models(config).await; let upgrade = available_models .iter() - .find(|preset| preset.model == config.model) + .find(|preset| preset.model == model) .and_then(|preset| preset.upgrade.as_ref()); if let Some(ModelUpgrade { @@ -139,14 +152,14 @@ async fn handle_model_migration_prompt_if_needed( migration_config_key, }) = upgrade { - if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key) { + if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) { return None; } let target_model = target_model.to_string(); - let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key); + let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); if !should_show_model_migration_prompt( - &config.model, + model, &target_model, hide_prompt_flag, available_models.clone(), @@ -154,13 +167,13 @@ async fn handle_model_migration_prompt_if_needed( return None; } - let prompt_copy = migration_copy_for_config(migration_config_key); + let prompt_copy = migration_copy_for_config(migration_config_key.as_str()); match run_model_migration_prompt(tui, prompt_copy).await { ModelMigrationOutcome::Accepted => { app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { migration_config: migration_config_key.to_string(), }); - config.model = target_model.to_string(); + 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 @@ -207,6 +220,7 @@ pub(crate) struct App { 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, @@ -235,8 +249,6 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, - - pub(crate) skills: Option>, } impl App { @@ -269,9 +281,14 @@ impl App { auth_manager.clone(), SessionSource::Cli, )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, + model.as_str(), &app_event_tx, auth_mode, conversation_manager.get_models_manager(), @@ -280,29 +297,15 @@ impl App { if let Some(exit_info) = exit_info { return Ok(exit_info); } - - let skills_outcome = load_skills(&config); - if !skills_outcome.errors.is_empty() { - match run_skill_error_prompt(tui, &skills_outcome.errors).await { - SkillErrorPromptOutcome::Exit => { - return Ok(AppExitInfo { - token_usage: TokenUsage::default(), - conversation_id: None, - update_action: None, - }); - } - SkillErrorPromptOutcome::Continue => {} - } + if let Some(updated_model) = config.model.clone() { + model = updated_model; } - let skills = if config.features.enabled(Feature::Skills) { - Some(skills_outcome.skills.clone()) - } else { - None - }; - let enhanced_keys_supported = tui.enhanced_keys_supported(); - + let model_family = conversation_manager + .get_models_manager() + .construct_model_family(model.as_str(), &config) + .await; let mut chat_widget = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { let init = crate::chatwidget::ChatWidgetInit { @@ -315,8 +318,8 @@ impl App { auth_manager: auth_manager.clone(), models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), - skills: skills.clone(), is_first_run, + model_family: model_family.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -341,8 +344,8 @@ impl App { auth_manager: auth_manager.clone(), models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), - skills: skills.clone(), is_first_run, + model_family: model_family.clone(), }; ChatWidget::new_from_existing( init, @@ -364,6 +367,7 @@ impl App { chat_widget, auth_manager: auth_manager.clone(), config, + current_model: model.clone(), active_profile, file_search, enhanced_keys_supported, @@ -377,7 +381,6 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -481,6 +484,11 @@ impl App { } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + let model_family = self + .server + .get_models_manager() + .construct_model_family(self.current_model.as_str(), &self.config) + .await; match event { AppEvent::NewSession => { let summary = session_summary( @@ -498,10 +506,11 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -547,14 +556,15 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, + model_family: model_family.clone(), }; self.chat_widget = ChatWidget::new_from_existing( init, resumed.conversation, resumed.session_configured, ); + self.current_model = model_family.get_model_slug().to_string(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -637,6 +647,19 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } + if let EventMsg::SessionConfigured(cfg) = &event.msg + && let Some(outcome) = cfg.skill_load_outcome.as_ref() + && !outcome.errors.is_empty() + { + let errors = skill_errors_from_outcome(outcome); + match run_skill_error_prompt(tui, &errors).await { + SkillErrorPromptOutcome::Exit => { + self.chat_widget.submit_op(Op::Shutdown); + return Ok(false); + } + SkillErrorPromptOutcome::Continue => {} + } + } self.chat_widget.handle_codex_event(event); } AppEvent::ConversationHistory(ev) => { @@ -677,12 +700,20 @@ impl App { self.on_update_reasoning_effort(effort); } AppEvent::UpdateModel(model) => { - self.chat_widget.set_model(&model); - self.config.model = model; + let model_family = self + .server + .get_models_manager() + .construct_model_family(&model, &self.config) + .await; + self.chat_widget.set_model(&model, model_family); + 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); } @@ -782,20 +813,17 @@ impl App { .await { Ok(()) => { - let reasoning_label = Self::reasoning_label(effort); + 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 { - self.chat_widget.add_info_message( - format!( - "Model changed to {model} {reasoning_label} for {profile} profile" - ), - None, - ); - } else { - self.chat_widget.add_info_message( - format!("Model changed to {model} {reasoning_label}"), - None, - ); + message.push_str(" for "); + message.push_str(profile); + message.push_str(" profile"); } + self.chat_widget.add_info_message(message, None); } Err(err) => { tracing::error!( @@ -995,6 +1023,13 @@ impl App { } } + 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() } @@ -1143,9 +1178,11 @@ mod tests { fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::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()); @@ -1156,6 +1193,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1169,7 +1207,6 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills: None, } } @@ -1180,9 +1217,11 @@ mod tests { ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender(); let config = chat_widget.config_ref().clone(); - let server = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "Test API Key", - ))); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::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()); @@ -1194,6 +1233,7 @@ mod tests { chat_widget, auth_manager, config, + current_model, active_profile: None, file_search, transcript_cells: Vec::new(), @@ -1207,7 +1247,6 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, - skills: None, }, rx, op_rx, @@ -1315,10 +1354,12 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: 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 @@ -1369,6 +1410,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, + skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 2f59872bced..671702d3082 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -338,8 +338,10 @@ impl App { ) { let conv = new_conv.conversation; let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); let init = crate::chatwidget::ChatWidgetInit { config: cfg, + model_family: model_family.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), initial_prompt: None, @@ -348,11 +350,11 @@ impl App { auth_manager: self.auth_manager.clone(), models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), - skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); // Trim transcript up to the selected user message and re-render it. self.trim_transcript_for_backtrack(nth_user_message); self.render_transcript_once(tui); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3a199593bbb..c92dab4b3a8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -74,6 +74,11 @@ pub(crate) enum AppEvent { 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, diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 768fe030d4c..d42861eb1d5 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -23,8 +23,6 @@ use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; -use codex_core::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -45,7 +43,6 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -345,18 +342,11 @@ impl From for ApprovalRequestState { id, command, reason, - risk, proposed_execpolicy_amendment, } => { - let reason = reason.filter(|item| !item.is_empty()); - let has_reason = reason.is_some(); let mut header: Vec> = Vec::new(); if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); - } - if let Some(risk) = risk.as_ref() { - header.extend(render_risk_lines(risk)); - } else if has_reason { header.push(Line::from("")); } let full_cmd = strip_bash_lc_and_escape(&command); @@ -419,28 +409,6 @@ impl From for ApprovalRequestState { } } -fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { - let level_span = match risk.risk_level { - SandboxRiskLevel::Low => "LOW".green().bold(), - SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(), - SandboxRiskLevel::High => "HIGH".red().bold(), - }; - - let mut lines = Vec::new(); - - let description = risk.description.trim(); - if !description.is_empty() { - lines.push(Line::from(vec![ - "Summary: ".into(), - description.to_string().into(), - ])); - } - - lines.push(vec!["Risk: ".into(), level_span].into()); - lines.push(Line::from("")); - lines -} - #[derive(Clone)] enum ApprovalVariant { Exec { @@ -570,7 +538,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), - risk: None, proposed_execpolicy_amendment: None, } } @@ -613,7 +580,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -652,7 +618,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -679,7 +644,6 @@ mod tests { id: "test".into(), command, reason: None, - risk: None, proposed_execpolicy_amendment: None, }; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4deb5125c12..919866b00c9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -43,6 +43,7 @@ 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; @@ -801,6 +802,10 @@ impl ChatComposer { 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. @@ -1579,6 +1584,56 @@ impl ChatComposer { } } + /// 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 = built_in_slash_commands() + .into_iter() + .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. @@ -1596,17 +1651,10 @@ impl ChatComposer { let cursor = self.textarea.cursor(); let caret_on_first_line = cursor <= first_line_end; - let is_editing_slash_command_name = if first_line.starts_with('/') && caret_on_first_line { - // Compute the end of the initial '/name' token (name may be empty yet). - let token_end = first_line - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(i, _)| i) - .unwrap_or(first_line.len()); - cursor <= token_end - } else { - false - }; + 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/..."). @@ -3873,4 +3921,82 @@ mod tests { 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" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 39bbfbd1822..e1e35ae9480 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -182,9 +182,9 @@ impl CommandPopup { GenericDisplayRow { name, match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), - is_current: false, display_shortcut: None, description: Some(description), + wrap_indent: None, } }) .collect() @@ -373,4 +373,23 @@ mod tests { 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(), false); + 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:?}" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 708b0047480..064e4f01370 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -129,9 +129,9 @@ impl WidgetRef for &FileSearchPopup { .indices .as_ref() .map(|v| v.iter().map(|&i| i as usize).collect()), - is_current: false, display_shortcut: None, description: None, + wrap_indent: None, }) .collect() }; diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index d294a472653..46d6daac601 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -28,6 +28,7 @@ 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; @@ -39,6 +40,7 @@ pub(crate) struct SelectionItem { 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, @@ -186,29 +188,35 @@ impl ListSelectionView { let is_selected = self.state.selected_idx == Some(visible_idx); let prefix = if is_selected { '›' } else { ' ' }; let name = item.name.as_str(); - let name_with_marker = if item.is_current { - format!("{name} (current)") + let marker = if item.is_current { + " (current)" + } else if item.is_default { + " (default)" } else { - item.name.clone() + "" }; + let name_with_marker = format!("{name}{marker}"); let n = visible_idx + 1; - let display_name = if self.is_searchable { + 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} {name_with_marker}") + format!("{prefix} ") } else { - format!("{prefix} {n}. {name_with_marker}") + 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, - is_current: item.is_current, description, + wrap_indent, } }) }) @@ -264,13 +272,46 @@ impl ListSelectionView { 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, .. - } => self.move_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, .. - } => self.move_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, .. @@ -558,6 +599,47 @@ mod tests { ); } + #[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::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8a4336f6fe8..c0d6007324b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -131,10 +131,20 @@ impl BottomPane { } } + pub fn set_skills(&mut self, skills: Option>) { + self.composer.set_skill_mentions(skills); + self.request_redraw(); + } + + #[cfg(test)] 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 @@ -570,7 +580,6 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, - risk: None, proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index 8042a75b28b..5107ab0ca91 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -19,8 +19,8 @@ pub(crate) struct GenericDisplayRow { pub name: String, pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) - pub is_current: bool, - pub description: Option, // optional grey text after the name + pub description: Option, // optional grey text after the name + pub wrap_indent: Option, // optional indent for wrapped lines } /// Compute a shared description-column start based on the widest visible name @@ -47,13 +47,30 @@ fn compute_desc_col( 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 = desc_col.saturating_sub(2); + 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; @@ -63,11 +80,12 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { 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); - if used_width + ch_w > name_limit { + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { truncated = true; break; } - used_width += ch_w; + used_width = next_width; if idx_iter.peek().is_some_and(|next| **next == char_idx) { idx_iter.next(); @@ -79,11 +97,12 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { } else { for ch in row.name.chars() { let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - if used_width + ch_w > name_limit { + let next_width = used_width.saturating_add(ch_w); + if next_width > name_limit { truncated = true; break; } - used_width += ch_w; + used_width = next_width; name_spans.push(ch.to_string().into()); } } @@ -161,24 +180,7 @@ pub(crate) fn render_rows( break; } - let GenericDisplayRow { - name, - match_indices, - display_shortcut, - is_current: _is_current, - description, - } = row; - - let mut full_line = build_full_line( - &GenericDisplayRow { - name: name.clone(), - match_indices: match_indices.clone(), - display_shortcut: *display_shortcut, - is_current: *_is_current, - description: description.clone(), - }, - desc_col, - ); + 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. @@ -190,9 +192,10 @@ pub(crate) fn render_rows( // 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(desc_col))); + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); let wrapped = word_wrap_line(&full_line, options); // Render the wrapped lines. @@ -256,9 +259,10 @@ pub(crate) fn measure_rows_height( .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(desc_col))); + .subsequent_indent(Line::from(" ".repeat(continuation_indent))); total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16); } total.max(1) diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 74c1b137ca1..3e0f79f84bb 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -90,9 +90,9 @@ impl SkillPopup { GenericDisplayRow { name, match_indices: indices, - is_current: false, display_shortcut: None, description: Some(description), + wrap_indent: None, } }) .collect() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3b57ce9d14..d7a451e825f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::openai_models::model_family::ModelFamily; use codex_core::openai_models::models_manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; @@ -43,8 +45,10 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SkillLoadOutcomeInfo; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; @@ -198,8 +202,9 @@ impl RateLimitWarningState { 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've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } @@ -216,8 +221,9 @@ impl RateLimitWarningState { 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've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } @@ -259,8 +265,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) auth_manager: Arc, pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, - pub(crate) skills: Option>, pub(crate) is_first_run: bool, + pub(crate) model_family: ModelFamily, } #[derive(Default)] @@ -277,6 +283,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, active_cell: Option>, config: Config, + model_family: ModelFamily, auth_manager: Arc, models_manager: Arc, session_header: SessionHeader, @@ -327,6 +334,8 @@ pub(crate) struct ChatWidget { feedback: codex_feedback::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, + // Current task start time + task_started_at: Option, } struct UserMessage { @@ -374,10 +383,19 @@ impl ChatWidget { self.bottom_pane.update_status_header(header); } + fn restore_retry_status_header_if_present(&mut self) { + if let Some(header) = self.retry_status_header.take() + && self.current_status_header != header + { + 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_from_outcome(event.skill_load_outcome.as_ref()); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -385,6 +403,7 @@ impl ChatWidget { self.session_header.set_model(&model_for_header); self.add_to_history(history_cell::new_session_info( &self.config, + &model_for_header, event, self.show_welcome_banner, )); @@ -401,6 +420,11 @@ impl ChatWidget { } } + fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { + let skills = outcome.map(skills_from_outcome); + self.bottom_pane.set_skills(skills); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -465,15 +489,13 @@ impl ChatWidget { } fn on_agent_reasoning_final(&mut self) { + let reasoning_summary_format = self.get_model_family().reasoning_summary_format; // At the end of a reasoning block, record transcript-only content. self.full_reasoning_buffer.push_str(&self.reasoning_buffer); - let model_family = self - .models_manager - .construct_model_family(&self.config.model, &self.config); if !self.full_reasoning_buffer.is_empty() { let cell = history_cell::new_reasoning_summary_block( self.full_reasoning_buffer.clone(), - &model_family, + reasoning_summary_format, ); self.add_boxed_history(cell); } @@ -499,17 +521,20 @@ impl ChatWidget { self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + self.task_started_at = Some(Instant::now()); 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(); + self.add_final_message_separator(); // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.suppressed_exec_calls.clear(); self.last_unified_wait = None; + self.task_started_at = None; self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. @@ -541,7 +566,7 @@ impl ChatWidget { fn context_remaining_percent(&self, info: &TokenUsageInfo) -> Option { info.model_context_window - .or(self.config.model_context_window) + .or(self.model_family.context_window) .map(|window| { info.last_token_usage .percent_of_context_window_remaining(window) @@ -613,7 +638,7 @@ impl ChatWidget { if high_usage && !self.rate_limit_switch_prompt_hidden() - && self.config.model != NUDGE_MODEL_SLUG + && self.model_family.get_model_slug() != NUDGE_MODEL_SLUG && !matches!( self.rate_limit_switch_prompt, RateLimitSwitchPromptState::Shown @@ -645,8 +670,12 @@ impl ChatWidget { self.suppressed_exec_calls.clear(); self.last_unified_wait = None; self.stream_controller = None; + self.task_started_at = None; self.maybe_show_pending_rate_limit_prompt(); } + pub(crate) fn get_model_family(&self) -> ModelFamily { + self.model_family.clone() + } fn on_error(&mut self, message: String) { self.finalize_turn(); @@ -811,6 +840,10 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } + 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, @@ -981,14 +1014,6 @@ impl ChatWidget { self.flush_active_cell(); if self.stream_controller.is_none() { - if self.needs_final_message_separator { - 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.stream_controller = Some(StreamController::new( self.last_rendered_width.get().map(|w| w.saturating_sub(2)), )); @@ -1001,6 +1026,16 @@ impl ChatWidget { self.request_redraw(); } + fn add_final_message_separator(&mut self) { + if self.needs_final_message_separator { + let elapsed_seconds = self + .task_started_at + .map(|start_time| start_time.elapsed().as_secs()); + self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); + self.needs_final_message_separator = false; + } + } + 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) { @@ -1008,11 +1043,7 @@ impl ChatWidget { } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => ( - vec![ev.call_id.clone()], - Vec::new(), - ExecCommandSource::Agent, - ), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), }; let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); @@ -1029,7 +1060,7 @@ impl ChatWidget { command, parsed, source, - None, + ev.interaction_input.clone(), self.config.animations, ))); } @@ -1080,7 +1111,6 @@ impl ChatWidget { id, command: ev.command, reason: ev.reason, - risk: ev.risk, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane @@ -1247,9 +1277,12 @@ impl ChatWidget { auth_manager, models_manager, feedback, - skills, is_first_run, + model_family, } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); @@ -1266,13 +1299,14 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - skills, + skills: None, }), active_cell: None, - config: config.clone(), + config, + model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1305,6 +1339,7 @@ impl ChatWidget { last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + task_started_at: None, }; widget.prefetch_rate_limits(); @@ -1328,9 +1363,10 @@ impl ChatWidget { auth_manager, models_manager, feedback, - skills, + model_family, .. } = common; + let model_slug = model_family.get_model_slug().to_string(); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1349,13 +1385,14 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, - skills, + skills: None, }), active_cell: None, - config: config.clone(), + config, + model_family, auth_manager, models_manager, - session_header: SessionHeader::new(config.model), + session_header: SessionHeader::new(model_slug), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), initial_images, @@ -1388,6 +1425,7 @@ impl ChatWidget { last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + task_started_at: None, }; widget.prefetch_rate_limits(); @@ -1715,6 +1753,16 @@ impl ChatWidget { items.push(UserInput::LocalImage { path }); } + 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(), + }); + } + } + self.codex_op_tx .send(Op::UserInput { items }) .unwrap_or_else(|e| { @@ -1763,9 +1811,15 @@ impl ChatWidget { /// `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); @@ -1785,7 +1839,7 @@ impl ChatWidget { EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(), EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { self.on_agent_reasoning_delta(text); - self.on_agent_reasoning_final() + self.on_agent_reasoning_final(); } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted(_) => self.on_task_started(), @@ -1823,6 +1877,7 @@ impl ChatWidget { 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), @@ -2003,12 +2058,14 @@ impl ChatWidget { self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), + &self.model_family, total_usage, context_usage, &self.conversation_id, self.rate_limit_snapshot.as_ref(), self.plan_type, Local::now(), + self.model_family.get_model_slug(), )); } fn stop_rate_limit_poller(&mut self) { @@ -2045,7 +2102,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.available_models.try_read().ok()?; + let models = self.models_manager.try_list_models().ok()?; models .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) @@ -2148,29 +2205,122 @@ impl ChatWidget { }); } - /// Open a popup to choose the model (stage 1). After selecting a model, - /// a second popup is shown to choose the reasoning effort. + /// 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) { - let current_model = self.config.model.clone(); + let current_model = self.model_family.get_model_slug().to_string(); let presets: Vec = // todo(aibrahim): make this async function - if let Ok(models) = self.models_manager.available_models.try_read() { - models.clone() - } else { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - None, - ); - return; + match self.models_manager.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment." + .to_string(), + None, + ); + return; + } }; + let current_label = presets + .iter() + .find(|preset| preset.model == current_model) + .map(|preset| preset.display_name.to_string()) + .unwrap_or_else(|| current_model.clone()); + + 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: model == 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 current_model = self.model_family.get_model_slug().to_string(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { - let description = if preset.description.is_empty() { - None - } else { - Some(preset.description.to_string()) - }; + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); let is_current = preset.model == current_model; let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; let preset_for_action = preset.clone(); @@ -2181,9 +2331,10 @@ impl ChatWidget { }); })]; items.push(SelectionItem { - name: preset.display_name.to_string(), + name: preset.display_name.clone(), description, is_current, + is_default: preset.is_default, actions, dismiss_on_select: single_supported_effort, ..Default::default() @@ -2202,6 +2353,36 @@ impl ChatWidget { }); } + 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, + })); + 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; @@ -2265,7 +2446,7 @@ impl ChatWidget { .or(Some(default_effort)); let model_slug = preset.model.to_string(); - let is_current_model = self.config.model == preset.model; + let is_current_model = self.model_family.get_model_slug() == preset.model; let highlight_choice = if is_current_model { self.config.model_reasoning_effort } else { @@ -2310,30 +2491,7 @@ impl ChatWidget { }; let model_for_action = model_slug.clone(); - let effort_for_action = choice.stored; - let actions: Vec = vec![Box::new(move |tx| { - 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, - })); - 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_for_action - .map(|e| e.to_string()) - .unwrap_or_else(|| "default".to_string()) - ); - })]; + let actions = Self::model_selection_actions(model_for_action, choice.stored); items.push(SelectionItem { name: effort_label, @@ -2843,9 +3001,9 @@ impl ChatWidget { } /// Set the model in the widget's config copy. - pub(crate) fn set_model(&mut self, model: &str) { + pub(crate) fn set_model(&mut self, model: &str, model_family: ModelFamily) { self.session_header.set_model(model); - self.config.model = model.to_string(); + self.model_family = model_family; } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { @@ -3328,5 +3486,33 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } +fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { + outcome + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + }) + .collect() +} + +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 +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index ca093f271aa..15511611a10 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -2,6 +2,8 @@ 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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap index 1ed73b5fa5c..8f9b559276a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -expression: visual +expression: term.backend().vt100().screen().contents() --- • -- Indented code block (4 spaces) SELECT * @@ -16,3 +16,4 @@ expression: visual "path": "C:\\Program Files\\App", "regex": "^foo.*(bar)?$" } +─ Worked for 0s ──────────────────────────────────────────────────────────────── diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap index 1b18a23d4d2..2f0f1412a1f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -3,4 +3,3 @@ 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/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 56a209ef73a..b4c76500ca1 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 @@ -5,11 +5,13 @@ 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. +› 1. gpt-5.1-codex-max (default) 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. robin Robin + 5. 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/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6f2e656a5e4..f03346ee52e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -10,6 +10,7 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -74,6 +75,7 @@ fn set_windows_sandbox_enabled(enabled: bool) { fn test_config() -> Config { // Use base defaults to avoid depending on host state. + Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), @@ -97,7 +99,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { #[test] fn resumed_initial_messages_render_history() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let conversation_id = ConversationId::new(); let rollout_file = NamedTempFile::new().unwrap(); @@ -120,6 +122,7 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), + skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; @@ -153,7 +156,7 @@ fn resumed_initial_messages_render_history() { /// Entering review mode uses the hint provided by the review request. #[test] fn entered_review_mode_uses_request_hint() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "review-start".into(), @@ -174,7 +177,7 @@ fn entered_review_mode_uses_request_hint() { /// Entering review mode renders the current changes banner when requested. #[test] fn entered_review_mode_defaults_to_current_changes_banner() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "review-start".into(), @@ -194,7 +197,7 @@ fn entered_review_mode_defaults_to_current_changes_banner() { /// the closing banner while clearing review mode state. #[test] fn exited_review_mode_emits_results_and_finishes() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let review = ReviewOutputEvent { findings: vec![ReviewFinding { @@ -228,7 +231,7 @@ fn exited_review_mode_emits_results_and_finishes() { /// Exiting review restores the pre-review context window indicator. #[test] fn review_restores_context_window_indicator() { - let (mut chat, mut rx, _ops) = make_chatwidget_manual(); + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); let context_window = 13_000; let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline. @@ -277,7 +280,7 @@ fn review_restores_context_window_indicator() { /// Receiving a TokenCount event without usage clears the context indicator. #[test] fn token_count_none_resets_context_indicator() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(); + let (mut chat, _rx, _ops) = make_chatwidget_manual(None); let context_window = 13_000; let pre_compact_tokens = 12_700; @@ -303,7 +306,7 @@ fn token_count_none_resets_context_indicator() { #[test] fn context_indicator_shows_used_tokens_when_window_unknown() { - let (mut chat, _rx, _ops) = make_chatwidget_manual(); + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")); chat.config.model_context_window = None; let auto_compact_limit = 200_000; @@ -345,9 +348,12 @@ 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(); - let conversation_manager = Arc::new(ConversationManager::with_auth(CodexAuth::from_api_key( - "test", - ))); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::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, @@ -359,8 +365,8 @@ async fn helpers_are_available_and_do_not_panic() { auth_manager, models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), - skills: None, is_first_run: true, + model_family, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -368,7 +374,9 @@ async fn helpers_are_available_and_do_not_panic() { } // --- Helpers for tests that need direct construction and event draining --- -fn make_chatwidget_manual() -> ( +fn make_chatwidget_manual( + model_override: Option<&str>, +) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, @@ -376,7 +384,13 @@ fn make_chatwidget_manual() -> ( let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); - let cfg = test_config(); + let mut cfg = test_config(); + 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 bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -394,9 +408,10 @@ fn make_chatwidget_manual() -> ( bottom_pane: bottom, active_cell: None, config: cfg.clone(), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), auth_manager: auth_manager.clone(), models_manager: Arc::new(ModelsManager::new(auth_manager)), - session_header: SessionHeader::new(cfg.model), + session_header: SessionHeader::new(resolved_model.clone()), initial_user_message: None, token_info: None, rate_limit_snapshot: None, @@ -427,6 +442,7 @@ fn make_chatwidget_manual() -> ( last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, + task_started_at: None, }; (widget, rx, op_rx) } @@ -443,7 +459,7 @@ pub(crate) fn make_chatwidget_manual_with_sender() -> ( tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { - let (widget, rx, op_rx) = make_chatwidget_manual(); + let (widget, rx, op_rx) = make_chatwidget_manual(None); let app_event_tx = widget.app_event_tx.clone(); (widget, app_event_tx, rx, op_rx) } @@ -506,16 +522,16 @@ fn rate_limit_warnings_emit_thresholds() { warnings, vec![ String::from( - "Heads up, you've used over 75% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 75% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", ), String::from( - "Heads up, you've used over 95% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 95% of your weekly limit. Run /status for a breakdown.", + "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" @@ -531,7 +547,7 @@ fn test_rate_limit_warnings_monthly() { assert_eq!( warnings, vec![String::from( - "Heads up, you've used over 75% of your monthly limit. Run /status for a breakdown.", + "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" ); @@ -539,7 +555,7 @@ fn test_rate_limit_warnings_monthly() { #[test] fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: None, @@ -588,7 +604,7 @@ fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { #[test] fn rate_limit_snapshot_updates_and_retains_plan_type() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { primary: Some(RateLimitWindow { @@ -641,10 +657,9 @@ fn rate_limit_snapshot_updates_and_retains_plan_type() { #[test] fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { - let (mut chat, _, _) = make_chatwidget_manual(); + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = NUDGE_MODEL_SLUG.to_string(); chat.on_rate_limit_snapshot(Some(snapshot(95.0))); @@ -657,8 +672,7 @@ fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { #[test] fn rate_limit_switch_prompt_shows_once_per_session() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.on_rate_limit_snapshot(Some(snapshot(90.0))); @@ -682,8 +696,7 @@ fn rate_limit_switch_prompt_shows_once_per_session() { #[test] fn rate_limit_switch_prompt_respects_hidden_notice() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.config.notices.hide_rate_limit_model_nudge = Some(true); @@ -698,8 +711,7 @@ fn rate_limit_switch_prompt_respects_hidden_notice() { #[test] fn rate_limit_switch_prompt_defers_until_task_complete() { let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); - let (mut chat, _, _) = make_chatwidget_manual(); - chat.config.model = "gpt-5".to_string(); + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(auth); chat.bottom_pane.set_task_running(true); @@ -719,10 +731,9 @@ fn rate_limit_switch_prompt_defers_until_task_complete() { #[test] fn rate_limit_switch_prompt_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - chat.config.model = "gpt-5".to_string(); chat.on_rate_limit_snapshot(Some(snapshot(92.0))); chat.maybe_show_pending_rate_limit_prompt(); @@ -735,7 +746,7 @@ fn rate_limit_switch_prompt_popup_snapshot() { #[test] fn exec_approval_emits_proposed_command_and_decision_history() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Trigger an exec approval request with a short, single-line command let ev = ExecApprovalRequestEvent { @@ -746,7 +757,6 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -780,7 +790,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { #[test] fn exec_approval_decision_truncates_multiline_and_long_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Multiline command: modal should show full command, history records decision only let ev_multi = ExecApprovalRequestEvent { @@ -791,7 +801,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -842,7 +851,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -952,9 +960,11 @@ fn active_blob(chat: &ChatWidget) -> String { } fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { - chat.models_manager - .available_models - .blocking_read() + let models = chat + .models_manager + .try_list_models() + .expect("models lock available"); + models .iter() .find(|&preset| preset.model == model) .cloned() @@ -963,7 +973,7 @@ fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { #[test] fn empty_enter_during_task_does_not_queue() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Simulate running task so submissions would normally be queued. chat.bottom_pane.set_task_running(true); @@ -977,7 +987,7 @@ fn empty_enter_during_task_does_not_queue() { #[test] fn alt_up_edits_most_recent_queued_message() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(true); @@ -1010,7 +1020,7 @@ fn alt_up_edits_most_recent_queued_message() { /// is queued repeatedly. #[test] fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Submit an initial prompt to seed history. chat.bottom_pane.set_composer_text("repeat me".to_string()); @@ -1036,7 +1046,7 @@ fn enqueueing_history_prompt_multiple_times_is_stable() { #[test] fn streaming_final_answer_keeps_task_running_state() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); @@ -1066,7 +1076,7 @@ fn streaming_final_answer_keeps_task_running_state() { #[test] fn ctrl_c_shutdown_ignores_caps_lock() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); @@ -1078,7 +1088,7 @@ fn ctrl_c_shutdown_ignores_caps_lock() { #[test] fn ctrl_c_cleared_prompt_is_recoverable_via_history() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); chat.bottom_pane.insert_str("draft message "); chat.bottom_pane @@ -1112,7 +1122,7 @@ fn ctrl_c_cleared_prompt_is_recoverable_via_history() { #[test] fn exec_history_cell_shows_working_then_completed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin command let begin = begin_exec(&mut chat, "call-1", "echo done"); @@ -1142,7 +1152,7 @@ fn exec_history_cell_shows_working_then_completed() { #[test] fn exec_history_cell_shows_working_then_failed() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin command let begin = begin_exec(&mut chat, "call-2", "false"); @@ -1164,9 +1174,52 @@ fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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:?}" + ); +} + #[test] fn exec_history_shows_unified_exec_startup_commands() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin = begin_exec_with_source( &mut chat, @@ -1194,7 +1247,7 @@ fn exec_history_shows_unified_exec_startup_commands() { /// OpenReviewCustomPrompt to the app event channel. #[test] fn review_popup_custom_prompt_action_sends_event() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Open the preset selection popup chat.open_review_popup(); @@ -1219,7 +1272,7 @@ fn review_popup_custom_prompt_action_sends_event() { #[test] fn slash_init_skips_when_project_doc_exists() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); @@ -1251,7 +1304,7 @@ fn slash_init_skips_when_project_doc_exists() { #[test] fn slash_quit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Quit); @@ -1260,7 +1313,7 @@ fn slash_quit_requests_exit() { #[test] fn slash_exit_requests_exit() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Exit); @@ -1269,7 +1322,7 @@ fn slash_exit_requests_exit() { #[test] fn slash_resume_opens_picker() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Resume); @@ -1278,7 +1331,7 @@ fn slash_resume_opens_picker() { #[test] fn slash_undo_sends_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Undo); @@ -1290,7 +1343,7 @@ fn slash_undo_sends_op() { #[test] fn slash_rollout_displays_current_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); chat.current_rollout_path = Some(rollout_path.clone()); @@ -1307,7 +1360,7 @@ fn slash_rollout_displays_current_path() { #[test] fn slash_rollout_handles_missing_path() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.dispatch_command(SlashCommand::Rollout); @@ -1326,7 +1379,7 @@ fn slash_rollout_handles_missing_path() { #[test] fn undo_success_events_render_info_messages() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-1".to_string(), @@ -1363,7 +1416,7 @@ fn undo_success_events_render_info_messages() { #[test] fn undo_failure_events_render_error_message() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-2".to_string(), @@ -1398,7 +1451,7 @@ fn undo_failure_events_render_error_message() { #[test] fn undo_started_hides_interrupt_hint() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "turn-hint".to_string(), @@ -1418,7 +1471,7 @@ fn undo_started_hides_interrupt_hint() { /// The commit picker shows only commit subjects (no timestamps). #[test] fn review_commit_picker_shows_subjects_without_timestamps() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1480,7 +1533,7 @@ fn review_commit_picker_shows_subjects_without_timestamps() { /// and uses the same text for the user-facing hint. #[test] fn custom_prompt_submit_sends_review_op() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.show_review_custom_prompt(); // Paste prompt text via ChatWidget handler, then submit @@ -1508,7 +1561,7 @@ fn custom_prompt_submit_sends_review_op() { /// Hitting Enter on an empty custom prompt view does not submit. #[test] fn custom_prompt_enter_empty_does_not_send() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.show_review_custom_prompt(); // Enter without any text @@ -1520,7 +1573,7 @@ fn custom_prompt_enter_empty_does_not_send() { #[test] fn view_image_tool_call_adds_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let image_path = chat.config.cwd.join("example.png"); chat.handle_codex_event(Event { @@ -1541,7 +1594,7 @@ fn view_image_tool_call_adds_history_cell() { // marker (replacing the spinner) and flushes it into history. #[test] fn interrupt_exec_marks_failed_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin a long-running command so we have an active exec cell with a spinner. begin_exec(&mut chat, "call-int", "sleep 1"); @@ -1570,7 +1623,7 @@ fn interrupt_exec_marks_failed_snapshot() { // suggesting the user to tell the model what to do differently and to use /feedback. #[test] fn interrupted_turn_error_message_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate an in-progress task so the widget is in a running state. chat.handle_codex_event(Event { @@ -1601,7 +1654,7 @@ fn interrupted_turn_error_message_snapshot() { /// parent popup, pressing Esc again dismisses all panels (back to normal mode). #[test] fn review_custom_prompt_escape_navigates_back_then_dismisses() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1636,7 +1689,7 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { /// 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(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the Review presets parent popup. chat.open_review_popup(); @@ -1723,9 +1776,7 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { #[test] fn model_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - - chat.config.model = "gpt-5-codex".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); chat.open_model_popup(); let popup = render_bottom_popup(&chat, 80); @@ -1734,7 +1785,7 @@ fn model_selection_popup_snapshot() { #[test] fn approvals_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.notices.hide_full_access_warning = None; chat.open_approvals_popup(); @@ -1773,7 +1824,7 @@ fn preset_matching_ignores_extra_writable_roots() { #[test] fn full_access_confirmation_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); let preset = builtin_approval_presets() .into_iter() @@ -1788,7 +1839,7 @@ fn full_access_confirmation_popup_snapshot() { #[cfg(target_os = "windows")] #[test] fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); let preset = builtin_approval_presets() .into_iter() @@ -1806,7 +1857,7 @@ fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { #[cfg(target_os = "windows")] #[test] fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); set_windows_sandbox_enabled(false); chat.config.forced_auto_mode_downgraded_on_windows = true; @@ -1828,10 +1879,9 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { #[test] fn model_reasoning_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1843,10 +1893,9 @@ fn model_reasoning_selection_popup_snapshot() { #[test] fn model_reasoning_selection_popup_extra_high_warning_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); @@ -1858,10 +1907,9 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { #[test] fn reasoning_popup_shows_extra_high_with_space() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); set_chatgpt_auth(&mut chat); - chat.config.model = "gpt-5.1-codex-max".to_string(); let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); @@ -1879,7 +1927,7 @@ fn reasoning_popup_shows_extra_high_with_space() { #[test] fn single_reasoning_option_skips_selection() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, @@ -1919,7 +1967,7 @@ fn single_reasoning_option_skips_selection() { #[test] fn feedback_selection_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the feedback category selection popup via slash command. chat.dispatch_command(SlashCommand::Feedback); @@ -1930,7 +1978,7 @@ fn feedback_selection_popup_snapshot() { #[test] fn feedback_upload_consent_popup_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Open the consent popup directly for a chosen category. chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug); @@ -1941,9 +1989,7 @@ fn feedback_upload_consent_popup_snapshot() { #[test] fn reasoning_popup_escape_returns_to_model_popup() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - - chat.config.model = "gpt-5.1".to_string(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); chat.open_model_popup(); let preset = get_available_model(&chat, "gpt-5.1-codex"); @@ -1955,13 +2001,13 @@ fn reasoning_popup_escape_returns_to_model_popup() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); let after_escape = render_bottom_popup(&chat, 80); - assert!(after_escape.contains("Select Model and Effort")); + assert!(after_escape.contains("Select Model")); assert!(!after_escape.contains("Select Reasoning Level")); } #[test] fn exec_history_extends_previous_when_consecutive() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // 1) Start "ls -la" (List) let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la"); @@ -1992,7 +2038,7 @@ fn exec_history_extends_previous_when_consecutive() { #[test] fn user_shell_command_renders_output_not_exploring() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let begin_ls = begin_exec_with_source( &mut chat, @@ -2015,7 +2061,7 @@ fn user_shell_command_renders_output_not_exploring() { #[test] 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(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) @@ -2039,7 +2085,7 @@ fn disabled_slash_command_while_task_running_snapshot() { #[test] 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(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). chat.config.approval_policy = AskForApproval::OnRequest; // Inject an exec approval request to display the approval modal. @@ -2051,7 +2097,6 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2094,7 +2139,7 @@ fn approval_modal_exec_snapshot() { // Ensures spacing looks correct when no reason text is provided. #[test] fn approval_modal_exec_without_reason_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.approval_policy = AskForApproval::OnRequest; let ev = ExecApprovalRequestEvent { @@ -2103,7 +2148,6 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2133,7 +2177,7 @@ fn approval_modal_exec_without_reason_snapshot() { // Snapshot test: patch approval modal #[test] fn approval_modal_patch_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.config.approval_policy = AskForApproval::OnRequest; // Build a small changeset and a reason/grant_root to exercise the prompt text. @@ -2172,7 +2216,7 @@ fn approval_modal_patch_snapshot() { #[test] fn interrupt_restores_queued_messages_into_composer() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); // Simulate a running task to enable queuing of user inputs. chat.bottom_pane.set_task_running(true); @@ -2211,7 +2255,7 @@ fn interrupt_restores_queued_messages_into_composer() { #[test] fn interrupt_prepends_queued_messages_before_existing_composer_text() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); chat.bottom_pane @@ -2249,7 +2293,7 @@ fn interrupt_prepends_queued_messages_before_existing_composer_text() { fn ui_snapshots_small_heights_idle() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (chat, _rx, _op_rx) = make_chatwidget_manual(); + let (chat, _rx, _op_rx) = make_chatwidget_manual(None); 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"); @@ -2266,7 +2310,7 @@ fn ui_snapshots_small_heights_idle() { fn ui_snapshots_small_heights_task_running() { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Activate status line chat.handle_codex_event(Event { id: "task-1".into(), @@ -2297,7 +2341,7 @@ fn ui_snapshots_small_heights_task_running() { fn status_widget_and_approval_modal_snapshot() { use codex_core::protocol::ExecApprovalRequestEvent; - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Begin a running task so the status indicator would be active. chat.handle_codex_event(Event { id: "task-1".into(), @@ -2322,7 +2366,6 @@ fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello world".into(), @@ -2350,7 +2393,7 @@ fn status_widget_and_approval_modal_snapshot() { // Ensures the VT100 rendering of the status indicator is stable when active. #[test] fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Activate the status indicator by simulating a task start. chat.handle_codex_event(Event { id: "task-1".into(), @@ -2377,7 +2420,7 @@ fn status_widget_active_snapshot() { #[test] fn background_event_updates_status_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "bg-1".into(), @@ -2393,7 +2436,7 @@ fn background_event_updates_status_header() { #[test] fn apply_patch_events_emit_history_cells() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -2491,7 +2534,7 @@ fn apply_patch_events_emit_history_cells() { #[test] fn apply_patch_manual_approval_adjusts_header() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -2540,7 +2583,7 @@ fn apply_patch_manual_approval_adjusts_header() { #[test] fn apply_patch_manual_flow_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let mut proposed_changes = HashMap::new(); proposed_changes.insert( @@ -2593,7 +2636,7 @@ fn apply_patch_manual_flow_snapshot() { #[test] fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); changes.insert( @@ -2632,7 +2675,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { #[test] fn apply_patch_full_flow_integration_like() { - let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); // 1) Backend requests approval let mut changes = HashMap::new(); @@ -2710,7 +2753,7 @@ fn apply_patch_full_flow_integration_like() { #[test] fn apply_patch_untrusted_shows_approval_modal() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); // Ensure approval policy is untrusted (OnRequest) chat.config.approval_policy = AskForApproval::OnRequest; @@ -2755,7 +2798,7 @@ fn apply_patch_untrusted_shows_approval_modal() { #[test] fn apply_patch_request_shows_diff_summary() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Ensure we are in OnRequest so an approval is surfaced chat.config.approval_policy = AskForApproval::OnRequest; @@ -2821,7 +2864,7 @@ fn apply_patch_request_shows_diff_summary() { #[test] fn plan_update_renders_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); let update = UpdatePlanArgs { explanation: Some("Adapting plan".to_string()), plan: vec![ @@ -2857,7 +2900,7 @@ fn plan_update_renders_history_cell() { #[test] fn stream_error_updates_status_indicator() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.bottom_pane.set_task_running(true); let msg = "Reconnecting... 2/5"; chat.handle_codex_event(Event { @@ -2882,7 +2925,7 @@ fn stream_error_updates_status_indicator() { #[test] fn warning_event_adds_warning_history_cell() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::Warning(WarningEvent { @@ -2899,9 +2942,42 @@ fn warning_event_adds_warning_history_cell() { ); } +#[test] +fn stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "task".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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), + }), + }); + 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!(chat.retry_status_header.is_none()); +} + #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Begin turn chat.handle_codex_event(Event { @@ -2955,7 +3031,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { #[test] fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // No deltas; only final reasoning followed by final message. chat.handle_codex_event(Event { @@ -2982,7 +3058,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { #[test] fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Stream some reasoning deltas first. chat.handle_codex_event(Event { @@ -3046,7 +3122,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { // then the exec block, another blank line, the status line, a blank line, and the composer. #[test] fn chatwidget_exec_and_status_layout_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); 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() }), @@ -3138,7 +3214,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { // E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks #[test] fn chatwidget_markdown_code_blocks_vt100_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); // Simulate a final agent message via streaming deltas instead of a single message @@ -3229,7 +3305,7 @@ printf 'fenced within fenced\n' #[test] fn chatwidget_tall() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); chat.handle_codex_event(Event { id: "t1".into(), msg: EventMsg::TaskStarted(TaskStartedEvent { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index e70a31ffc0e..41470673668 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -27,7 +27,6 @@ use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; use codex_core::config::types::ReasoningSummaryFormat; -use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; @@ -622,6 +621,7 @@ impl HistoryCell for SessionInfoCell { pub(crate) fn new_session_info( config: &Config, + requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, ) -> SessionInfoCell { @@ -680,10 +680,10 @@ pub(crate) fn new_session_info( { parts.push(Box::new(tooltips)); } - if config.model != model { + if requested_model != model { let lines = vec![ "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), + format!("requested: {requested_model}").into(), format!("used: {model}").into(), ]; parts.push(Box::new(PlainHistoryCell { lines })); @@ -1421,9 +1421,9 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, - model_family: &ModelFamily, + reasoning_summary_format: ReasoningSummaryFormat, ) -> Box { - if model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental { + if reasoning_summary_format == ReasoningSummaryFormat::Experimental { // Experimental format is following: // ** header ** // @@ -1513,8 +1513,6 @@ mod tests { use crate::exec_cell::CommandOutput; use crate::exec_cell::ExecCall; use crate::exec_cell::ExecCell; - use codex_core::AuthManager; - use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -1527,7 +1525,6 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; - use std::sync::Arc; use codex_core::protocol::ExecCommandSource; use mcp_types::CallToolResult; @@ -2325,14 +2322,10 @@ mod tests { } #[test] fn reasoning_summary_block() { - let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &model_family, + reasoning_format, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2344,13 +2337,11 @@ mod tests { #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { - let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); - let cell = - new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &model_family); + let reasoning_format = ReasoningSummaryFormat::Experimental; + let cell = new_reasoning_summary_block( + "Detailed reasoning goes here.".to_string(), + reasoning_format, + ); let rendered = render_transcript(cell.as_ref()); assert_eq!(rendered, vec!["• Detailed reasoning goes here."]); @@ -2359,14 +2350,11 @@ mod tests { #[test] fn reasoning_summary_block_respects_config_overrides() { let mut config = test_config(); - config.model = "gpt-3.5-turbo".to_string(); + config.model = Some("gpt-3.5-turbo".to_string()); config.model_supports_reasoning_summaries = Some(true); config.model_reasoning_summary_format = Some(ReasoningSummaryFormat::Experimental); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - - let model_family = models_manager.construct_model_family(&config.model, &config); + let model_family = + ModelsManager::construct_model_family_offline(&config.model.clone().unwrap(), &config); assert_eq!( model_family.reasoning_summary_format, ReasoningSummaryFormat::Experimental @@ -2374,7 +2362,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(), - &model_family, + model_family.reasoning_summary_format, ); let rendered_display = render_lines(&cell.display_lines(80)); @@ -2383,14 +2371,10 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_header_is_missing() { - let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing".to_string(), - &model_family, + reasoning_format, ); let rendered = render_transcript(cell.as_ref()); @@ -2399,14 +2383,10 @@ mod tests { #[test] fn reasoning_summary_block_falls_back_when_summary_is_missing() { - let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level reasoning without closing**".to_string(), - &model_family, + reasoning_format.clone(), ); let rendered = render_transcript(cell.as_ref()); @@ -2414,7 +2394,7 @@ mod tests { let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), - &model_family, + reasoning_format, ); let rendered = render_transcript(cell.as_ref()); @@ -2423,14 +2403,10 @@ mod tests { #[test] fn reasoning_summary_block_splits_header_and_summary_when_present() { - let config = test_config(); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let models_manager = Arc::new(ModelsManager::new(auth_manager)); - let model_family = models_manager.construct_model_family(&config.model, &config); + let reasoning_format = ReasoningSummaryFormat::Experimental; let cell = new_reasoning_summary_block( "**High level plan**\n\nWe should fix the bug next.".to_string(), - &model_family, + reasoning_format, ); let rendered_display = render_lines(&cell.display_lines(80)); diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 515419ee043..f277f073845 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -78,6 +78,7 @@ impl From<&KeyBinding> for Span<'static> { let modifiers = modifiers_to_string(*modifiers); let key = match key { KeyCode::Enter => "enter".to_string(), + KeyCode::Char(' ') => "space".to_string(), KeyCode::Up => "↑".to_string(), KeyCode::Down => "↓".to_string(), KeyCode::Left => "←".to_string(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0aa422cc614..71a47d1198d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -218,7 +218,6 @@ pub async fn run_main( include_apply_patch_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; @@ -270,6 +269,7 @@ pub async fn run_main( let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) + .with_ansi(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1f93fd9a4fd..cbce1f1bb01 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -292,7 +292,9 @@ fn gpt_5_1_codex_max_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1-codex-max".cyan().underlined(), + "https://openai.com/index/gpt-5-1-codex-max/" + .cyan() + .underlined(), ".".into(), ]), ], @@ -312,7 +314,7 @@ fn gpt5_migration_copy() -> ModelMigrationCopy { ), Line::from(vec![ "Learn more at ".into(), - "www.openai.com/index/gpt-5-1".cyan().underlined(), + "https://openai.com/index/gpt-5-1/".cyan().underlined(), ".".into(), ]), Line::from(vec!["Press enter to continue".dim()]), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70ef..46aaba86442 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -66,11 +66,18 @@ impl Overlay { const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); +const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); +const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); +const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); +const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); +const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); +const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); +const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); @@ -234,19 +241,32 @@ impl PagerView { fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { - e if KEY_UP.is_press(e) => { + e if KEY_UP.is_press(e) || KEY_K.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } - e if KEY_DOWN.is_press(e) => { + e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } - e if KEY_PAGE_UP.is_press(e) => { + e if KEY_PAGE_UP.is_press(e) + || KEY_SHIFT_SPACE.is_press(e) + || KEY_CTRL_B.is_press(e) => + { + let page_height = self.page_height(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_sub(page_height); + } + e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => { + let page_height = self.page_height(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_add(page_height); + } + e if KEY_CTRL_D.is_press(e) => { let area = self.content_area(tui.terminal.viewport_area); - self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); + let half_page = (area.height as usize).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_add(half_page); } - e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { + e if KEY_CTRL_U.is_press(e) => { let area = self.content_area(tui.terminal.viewport_area); - self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); + let half_page = (area.height as usize).saturating_add(1) / 2; + self.scroll_offset = self.scroll_offset.saturating_sub(half_page); } e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; @@ -263,6 +283,16 @@ impl PagerView { Ok(()) } + /// Returns the height of one page in content rows. + /// + /// Prefers the last rendered content height (excluding header/footer chrome); + /// if no render has occurred yet, falls back to the content area height + /// computed from the given viewport. + fn page_height(&self, viewport_area: Rect) -> usize { + self.last_content_height + .unwrap_or_else(|| self.content_area(viewport_area).height as usize) + } + fn update_last_content_height(&mut self, height: u16) { self.last_content_height = Some(height as usize); } @@ -812,6 +842,100 @@ mod tests { assert_snapshot!(term.backend()); } + /// Render transcript overlay and return visible line numbers (`line-NN`) in order. + fn transcript_line_numbers(overlay: &mut TranscriptOverlay, area: Rect) -> Vec { + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + let top_h = area.height.saturating_sub(3); + let top = Rect::new(area.x, area.y, area.width, top_h); + let content_area = overlay.view.content_area(top); + + let mut nums = Vec::new(); + for y in content_area.y..content_area.bottom() { + let mut line = String::new(); + for x in content_area.x..content_area.right() { + line.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + if let Some(n) = line + .split_whitespace() + .find_map(|w| w.strip_prefix("line-")) + .and_then(|s| s.parse().ok()) + { + nums.push(n); + } + } + nums + } + + #[test] + fn transcript_overlay_paging_is_continuous_and_round_trips() { + let mut overlay = TranscriptOverlay::new( + (0..50) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line-{i:02}"))], + }) as Arc + }) + .collect(), + ); + let area = Rect::new(0, 0, 40, 15); + + // Prime layout so last_content_height is populated and paging uses the real content height. + let mut buf = Buffer::empty(area); + overlay.view.scroll_offset = 0; + overlay.render(area, &mut buf); + let page_height = overlay.view.page_height(area); + + // Scenario 1: starting from the top, PageDown should show the next page of content. + overlay.view.scroll_offset = 0; + let page1 = transcript_line_numbers(&mut overlay, area); + let page1_len = page1.len(); + let expected_page1: Vec = (0..page1_len).collect(); + assert_eq!( + page1, expected_page1, + "first page should start at line-00 and show a full page of content" + ); + + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let page2 = transcript_line_numbers(&mut overlay, area); + assert_eq!( + page2.len(), + page1_len, + "second page should have the same number of visible lines as the first page" + ); + let expected_page2_first = *page1.last().unwrap() + 1; + assert_eq!( + page2[0], expected_page2_first, + "second page after PageDown should immediately follow the first page" + ); + + // Scenario 2: from an interior offset (start=3), PageDown then PageUp should round-trip. + let interior_offset = 3usize; + overlay.view.scroll_offset = interior_offset; + let before = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let _ = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height); + let after = transcript_line_numbers(&mut overlay, area); + assert_eq!( + before, after, + "PageDown+PageUp from interior offset ({interior_offset}) should round-trip" + ); + + // Scenario 3: from the top of the second page, PageUp then PageDown should round-trip. + overlay.view.scroll_offset = page_height; + let before2 = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height); + let _ = transcript_line_numbers(&mut overlay, area); + overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height); + let after2 = transcript_line_numbers(&mut overlay, area); + assert_eq!( + before2, after2, + "PageUp+PageDown from the top of the second page should round-trip" + ); + } + #[test] fn static_overlay_wraps_long_lines() { let mut overlay = StaticOverlay::with_title( diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap index 5b3136803fb..1f95142169b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt.snap @@ -9,7 +9,7 @@ expression: terminal.backend() than its predecessors and capable of long-running project-scale work. - Learn more at www.openai.com/index/gpt-5-1-codex-max. + Learn more at https://openai.com/index/gpt-5-1-codex-max/. Choose how you'd like Codex to proceed. diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap index 5a0ccd9b5b3..52718e5793c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap index 5a0ccd9b5b3..52718e5793c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_codex_mini.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap index 5a0ccd9b5b3..52718e5793c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__model_migration__tests__model_migration_prompt_gpt5_family.snap @@ -10,6 +10,6 @@ expression: terminal.backend() You can continue using legacy models by specifying them directly with the -m option or in your config.toml. - Learn more at www.openai.com/index/gpt-5-1. + Learn more at https://openai.com/index/gpt-5-1/. Press enter to continue diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 797eded5fa1..aac981c764e 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,6 +7,7 @@ use chrono::DateTime; use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; +use codex_core::openai_models::model_family::ModelFamily; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; @@ -70,23 +71,27 @@ struct StatusHistoryCell { pub(crate) fn new_status_output( config: &Config, auth_manager: &AuthManager, + model_family: &ModelFamily, total_usage: &TokenUsage, context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> CompositeHistoryCell { let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let card = StatusHistoryCell::new( config, auth_manager, + model_family, total_usage, context_usage, session_id, rate_limits, plan_type, now, + model_name, ); CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) @@ -97,15 +102,17 @@ impl StatusHistoryCell { fn new( config: &Config, auth_manager: &AuthManager, + model_family: &ModelFamily, total_usage: &TokenUsage, context_usage: Option<&TokenUsage>, session_id: &Option, rate_limits: Option<&RateLimitSnapshotDisplay>, plan_type: Option, now: DateTime, + model_name: &str, ) -> Self { - let config_entries = create_config_summary_entries(config); - let (model_name, model_details) = compose_model_display(config, &config_entries); + let config_entries = create_config_summary_entries(config, model_name); + let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries .iter() .find(|(k, _)| *k == "approval") @@ -119,7 +126,7 @@ impl StatusHistoryCell { let agents_summary = compose_agents_summary(config); let account = compose_account_display(auth_manager, plan_type); let session_id = session_id.as_ref().map(std::string::ToString::to_string); - let context_window = config.model_context_window.and_then(|window| { + let context_window = model_family.context_window.and_then(|window| { context_usage.map(|usage| StatusContextWindowData { percent_remaining: usage.percent_of_context_window_remaining(window), tokens_in_context: usage.tokens_in_context_window(), diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index cb6b7b54b29..8ba7ec37751 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -17,7 +17,7 @@ fn normalize_agents_display_path(path: &Path) -> String { } pub(crate) fn compose_model_display( - config: &Config, + model_name: &str, entries: &[(&str, String)], ) -> (String, Vec) { let mut details: Vec = Vec::new(); @@ -33,7 +33,7 @@ pub(crate) fn compose_model_display( } } - (config.model.clone(), details) + (model_name.to_string(), details) } pub(crate) fn compose_agents_summary(config: &Config) -> String { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 35989883f13..53c728526a2 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -8,6 +8,8 @@ use codex_core::AuthManager; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +use codex_core::openai_models::model_family::ModelFamily; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; @@ -37,6 +39,10 @@ fn test_auth_manager(config: &Config) -> AuthManager { ) } +fn test_model_family(model_slug: &str, config: &Config) -> ModelFamily { + ModelsManager::construct_model_family_offline(model_slug, config) +} + fn render_lines(lines: &[Line<'static>]) -> Vec { lines .iter() @@ -82,7 +88,7 @@ fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + 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; @@ -124,15 +130,20 @@ fn status_snapshot_includes_reasoning_details() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); + let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -148,7 +159,7 @@ fn status_snapshot_includes_reasoning_details() { fn status_snapshot_includes_monthly_limit() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.cwd = PathBuf::from("/workspace/tests"); @@ -177,15 +188,19 @@ fn status_snapshot_includes_monthly_limit() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -218,15 +233,19 @@ fn status_snapshot_shows_unlimited_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -258,15 +277,19 @@ fn status_snapshot_shows_positive_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -298,15 +321,19 @@ fn status_snapshot_hides_zero_credits() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -336,15 +363,19 @@ fn status_snapshot_hides_when_has_no_credits_flag() { plan_type: None, }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); assert!( @@ -357,7 +388,7 @@ fn status_snapshot_hides_when_has_no_credits_flag() { fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -374,15 +405,19 @@ fn status_card_token_usage_excludes_cached_tokens() { .single() .expect("timestamp"); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, None, None, now, + &model_slug, ); let rendered = render_lines(&composite.display_lines(120)); @@ -396,7 +431,7 @@ fn status_card_token_usage_excludes_cached_tokens() { fn status_snapshot_truncates_in_narrow_terminal() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + 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; @@ -427,15 +462,19 @@ fn status_snapshot_truncates_in_narrow_terminal() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(70)); if cfg!(windows) { @@ -452,7 +491,7 @@ fn status_snapshot_truncates_in_narrow_terminal() { fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -469,15 +508,19 @@ fn status_snapshot_shows_missing_limits_message() { .single() .expect("timestamp"); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, None, None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -493,7 +536,7 @@ fn status_snapshot_shows_missing_limits_message() { fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -529,15 +572,19 @@ fn status_snapshot_includes_credits_and_limits() { }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -553,7 +600,7 @@ fn status_snapshot_includes_credits_and_limits() { fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -577,15 +624,19 @@ fn status_snapshot_shows_empty_limits_message() { .expect("timestamp"); let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, captured_at, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -601,7 +652,7 @@ fn status_snapshot_shows_empty_limits_message() { fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex-max".to_string(); + config.model = Some("gpt-5.1-codex-max".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -634,15 +685,19 @@ fn status_snapshot_shows_stale_limits_message() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -658,7 +713,7 @@ fn status_snapshot_shows_stale_limits_message() { fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home); - config.model = "gpt-5.1-codex".to_string(); + config.model = Some("gpt-5.1-codex".to_string()); config.cwd = PathBuf::from("/workspace/tests"); let auth_manager = test_auth_manager(&config); @@ -695,15 +750,19 @@ fn status_snapshot_cached_limits_hide_credits_without_flag() { let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let now = captured_at + ChronoDuration::minutes(20); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &usage, Some(&usage), &None, Some(&rate_display), None, now, + &model_slug, ); let mut rendered_lines = render_lines(&composite.display_lines(80)); if cfg!(windows) { @@ -742,15 +801,19 @@ fn status_context_window_uses_last_usage() { .single() .expect("timestamp"); + let model_slug = ModelsManager::get_model_offline(config.model.as_deref()); + let model_family = test_model_family(&model_slug, &config); let composite = new_status_output( &config, &auth_manager, + &model_family, &total_usage, Some(&last_usage), &None, None, None, now, + &model_slug, ); let rendered_lines = render_lines(&composite.display_lines(80)); let context_line = rendered_lines diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 642b9ca2b71..e850c32fe67 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -125,13 +125,10 @@ impl StatusIndicatorWidget { elapsed } + #[cfg(test)] fn elapsed_seconds_at(&self, now: Instant) -> u64 { self.elapsed_duration_at(now).as_secs() } - - pub fn elapsed_seconds(&self) -> u64 { - self.elapsed_seconds_at(Instant::now()) - } } impl Renderable for StatusIndicatorWidget { diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml new file mode 100644 index 00000000000..06308e996c9 --- /dev/null +++ b/codex-rs/tui2/Cargo.toml @@ -0,0 +1,116 @@ +[package] +name = "codex-tui2" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_tui2" +path = "src/lib.rs" + +[[bin]] +name = "codex-tui2" +path = "src/main.rs" + +[features] +# Keep feature surface aligned with codex-tui while tui2 evolves separately. +vt100-tests = [] +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-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-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 } +opentelemetry-appender-tracing = { 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-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 } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", + "test-util", + "time", +] } +tokio-stream = { workspace = true } +toml = { 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] +codex-core = { workspace = true, features = ["test-support"] } +assert_matches = { 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/frames/blocks/frame_1.txt b/codex-rs/tui2/frames/blocks/frame_1.txt new file mode 100644 index 00000000000..8c3263f5184 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_1.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒██▒▒██▒ + ▒▒█▓█▒█▓█▒▒░░▒▒ ▒ █▒ + █░█░███ ▒░ ░ █░ ░▒░░░█ + ▓█▒▒████▒ ▓█░▓░█ + ▒▒▓▓█▒░▒░▒▒ ▓░▒▒█ + ░█ █░ ░█▓▓░░█ █▓▒░░█ + █▒ ▓█ █▒░█▓ ░▒ ░▓░ + ░░▒░░ █▓▓░▓░█ ░░ + ░▒░█░ ▓░░▒▒░ ▓░██████▒██ ▒ ░ + ▒░▓█ ▒▓█░ ▓█ ░ ░▒▒▒▓▓███░▓█▓█░ + ▒▒▒ ▒ ▒▒█▓▓░ ░▒████ ▒█ ▓█▓▒▓ + █▒█ █ ░ ██▓█▒░ + ▒▒█░▒█▒ ▒▒▒█░▒█ + ▒██▒▒ ██▓▓▒▓▓▓▒██▒█░█ + ░█ █░░░▒▒▒█▒▓██ + \ 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 new file mode 100644 index 00000000000..a6fbbf1a4b8 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_10.txt @@ -0,0 +1,17 @@ + + ▒████▒██▒ + ██░███▒░▓▒██ + ▒▒█░░▓░░▓░█▒██ + ░▒▒▓▒░▓▒▓▒███▒▒█ + ▓ ▓░░ ░▒ ██▓▒▓░▓ + ░░ █░█░▓▓▒ ░▒ ░ + ▒ ░█ █░░░░█ ░▓█ + ░░▒█▓█░░▓▒░▓▒░░ + ░▒ ▒▒░▓░░█▒█▓░░ + ░ █░▒█░▒▓▒█▒▒▒░█░ + █ ░░░░░ ▒█ ▒░░ + ▒░██▒██ ▒░ █▓▓ + ░█ ░░░░██▓█▓░▓░ + ▓░██▓░█▓▒ ▓▓█ + ██ ▒█▒▒█▓█ + \ 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 new file mode 100644 index 00000000000..88e3dfa7c58 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_11.txt @@ -0,0 +1,17 @@ + + ███████▒ + ▓ ▓░░░▒▒█ + ▓ ▒▒░░▓▒█▓▒█ + ░▒▒░░▒▓█▒▒▓▓ + ▒ ▓▓▒░█▒█▓▒░░█ + ░█░░░█▒▓▓░▒▓░░ + ██ █░░░░░░▒░▒▒ + ░ ░░▓░░▒▓ ░ ░ + ▓ █░▓░░█▓█░▒░ + ██ ▒░▓▒█ ▓░▒░▒ + █░▓ ░░░░▒▓░▒▒░ + ▒▒▓▓░▒█▓██▓░░ + ▒ █░▒▒▒▒░▓ + ▒█ █░░█▒▓█░ + ▒▒ ███▒█░ + \ 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 new file mode 100644 index 00000000000..c6c0ef3e87d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_12.txt @@ -0,0 +1,17 @@ + + █████▓ + █▒░▒▓░█▒ + ░▓▒██ + ▓█░░░▒▒ ░ + ░ █░░░░▓▓░ + ░█▓▓█▒ ▒░ + ░ ░▓▒░░▒ + ░ ▓█▒░░ + ██ ░▓░░█░░ + ░ ▓░█▓█▒ + ░▓ ░ ▒██▓ + █ █░ ▒█░ + ▓ ██░██▒░ + █▒▓ █░▒░░ + ▒ █░▒▓▓ + \ 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 new file mode 100644 index 00000000000..7a090e51e33 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_13.txt @@ -0,0 +1,17 @@ + + ▓████ + ░▒▒░░ + ░░▒░ + ░██░▒ + █ ░░ + ▓▓░░ + █ ░░ + █ ░ + ▓█ ▒░▓ + ░ █▒░ + █░▓▓ ░░ + ░▒▒▒░ + ░██░▒ + █▒▒░▒ + █ ▓ ▒ + \ 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 new file mode 100644 index 00000000000..f5e74d12b7e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_14.txt @@ -0,0 +1,17 @@ + + ████▓ + █▓▒▒▓▒ + ░▒░░▓ ░ + ░░▓░ ▒░█ + ░░░▒ ░ + ░█░░ █░ + ░░░░ ▓ █ + ░░▒░░ ▒ + ░░░░ + ▒▓▓ ▓▓ + ▒░ █▓█░ + ░█░░▒▒▒░ + ▓ ░▒▒▒░ + ░▒▓█▒▒▓ + ▒█ █▒▓ + \ 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 new file mode 100644 index 00000000000..f04599ea27d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_15.txt @@ -0,0 +1,17 @@ + + █████░▒ + ░█▒░░▒▓██ + ▓▓░█▒▒░ █░ + ░▓░ ▓▓█▓▒▒░ + ░░▒ ▒▒░░▓ ▒░ + ▒░░▓░░▓▓░ + ░░ ░░░░░░█░ + ░░▓░░█░░░ █▓░ + ░░████░░░▒▓▓░ + ░▒░▓▓░▒░█▓ ▓░ + ░▓░░░░▒░ ░ ▓ + ░██▓▒░░▒▓ ▒ + █░▒█ ▓▓▓░ ▓░ + ░▒░░▒▒▓█▒▓ + ▒▒█▒▒▒▒▓ + ░░ \ 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 new file mode 100644 index 00000000000..1eb080286ec --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_16.txt @@ -0,0 +1,17 @@ + + ▒▒█ ███░▒ + ▓▒░░█░░▒░▒▒ + ░▓▓ ▒▓▒▒░░ █▒ + ▓▓▓ ▓█▒▒░▒░░██░ + ░░▓▒▓██▒░░█▓░░▒ + ░░░█░█ ░▒▒ ░ ░▓░ + ▒▒░ ▓░█░░░░▓█ █ ░ + ░▓▓ ░░░░▓░░░ ▓ ░░ + ▒▒░░░█░▓▒░░ ██ ▓ + █ ▒▒█▒▒▒█░▓▒░ █▒░ + ░░░█ ▓█▒░▓ ▓▓░░░ + ░░█ ░░ ░▓▓█ ▓ + ▒░█ ░ ▓█▓▒█░ + ▒░░ ▒█░▓▓█▒░ + █▓▓▒▒▓▒▒▓█ + \ 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 new file mode 100644 index 00000000000..dd5f5c8da5f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_17.txt @@ -0,0 +1,17 @@ + + █▒███▓▓░█▒ + ▒▓██░░░█▒█░█ ▒█ + ██▒▓▒▒▒░██ ░░░▒ ▒ + ▓░▓▒▓░ ▒░ █░▓▒░░░▒▒ + ░▓▒ ░ ░ ▓▒▒▒▓▓ █ + ░▒██▓░ █▓▓░ ▓█▒▓░▓▓ + █ ▓▓░ █▓▓░▒ █ ░░▓▒░ + ▓ ▒░ ▓▓░░▓░█░░▒▓█ + █▓█▓▒▒▒█░▒▒░▒▒▓▒░░░ ░ + ░ ▒▓▒▒░▓█▒▓░░▒ ▒███▒ + ▒▒▒▓ ████▒▒░█▓▓▒ ▒█ + ▒░░▒█ ░▓░░░ ▓ + ▒▒▒ █▒▒ ███▓▒▒▓ + █ ░██▒▒█░▒▓█▓░█ + ░█▓▓▒██░█▒██ + ░ \ 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 new file mode 100644 index 00000000000..a6c93e6c01d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_18.txt @@ -0,0 +1,17 @@ + + ▒▒▒█▒▒█▓░█▒ + ▒█ ▒▓███░▒▒█ █▓▓▒ + ▒▓▓░█ █▒ █ ▓▒ █▓▓▒ █ + █░░█▓█▒ █ █▒░▒▓▒░▒▓▒▒▒█ + ▒▒▓▓ ▓░ ▒ █▒▒▓░▓░▒▒▓▒▒▒ + ▓▒░ ██░▓▒▒▒▓███░█▓▓▒▓░▓░ + ░░▒▓▓ █▓█▓░ ▒▓ █░▒░▒█ + ▒▓░░ ▒▒ ░░▓▒ ░▓░ + ▒ █▒▒▒▓▒▓█░░█░█▓▒█ ░█░░ + ▒▒▒░█▒█ ░░▓▒▒▒▒░░░▒▓░░▒ █ + ░▓░▒░ █████░ ▒▒▒▓░▓█▓░▓░ + ▒▒ █▒█ ░░█ ▓█▒█ + ▒▒██▒▒▓ ▒█▒▒▓▒█░ + █░▓████▒▒▒▒██▒▓▒██ + ░░▒▓▒▒█▓█ ▓█ + ░ \ 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 new file mode 100644 index 00000000000..73341b5d581 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_19.txt @@ -0,0 +1,17 @@ + + ▒▒▒▒█░█▒▒░▓▒ + ▒█░░░▒▓▒▒▒▒█▒█░███ + ██▓▓▓ ░██░ ░█▓█░█▓▒ + ▓▓░██▒░ ▒▒▒██▒░██ + ░░▓░▓░ █░▒ ▓ ░▒ ░▒█ + ░▒▓██ ▒░█░▓ ▓▓ █▓█░ + ▒▒░░█ ▓█▒▓░██░ ▓▓▓█░ + ░░░░ ░▓ ▒░ █ ░ ░░░ + ░█░▒█▒▓▓▒▒▒░░░░██▓█░▓ ▒ ░░ + ▒▓▓█░▒█▓▒██▒█░█ ▒▒ ▓▒▒▒█▓▓░▒ + █▒ ▓█░ ██ ▒▒▒▓░▓▓ ▓▓█ + ▒▒▒█▒▒ ░▓▓▒▓▓█ + █ ▒▒░░██ █▓▒▓▓░▓░ + █ ▓░█▓░█▒▒▒▓▓█ ▓█░█ + ░▓▒▓▓█▒█▓▒█▓▒ + \ 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 new file mode 100644 index 00000000000..1c7578c970e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_2.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▒█▒▒▒██▒ + ▒██▓█▓█░░░▒░░▒▒█░██▒ + █░█░▒██░█░░ ░ █▒█▓░░▓░█ + ▒░▓▒▓████▒ ▓█▒░▓░█ + █▒ ▓█▒░▒▒▒▒▒ ▒█░▒░█ + █▓█ ░ ░█▒█▓▒█ ▒▒░█░ + █░██░ ▒▓░▓░▒░█ ▓ ░ ░ + ░ ▒░ █░█░░▓█ ░█▓▓░ + █ ▒░ ▓░▒▒▒░ ▓░█████████░▒░░█ + ▒▒█░ ▓░░█ ▓█ ░▒▒▒▒▒▒▓▓▒▒░█▓ ░ + ▒▒▒ █ █▒▓▓░█ ░ ███████ ░██░░ + █▒▒▓▓█ ░ ██▓▓██ + ▓▒▒▒░██ █▒▒█ ▒░ + ░░▒▓▒▒ ██▓▓▒▓▓▓▒█░▒░░█ + ░████░░▒▒▒▒░▓▓█ + \ 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 new file mode 100644 index 00000000000..3e0c5f0d9ce --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_20.txt @@ -0,0 +1,17 @@ + + ▒▒█▒░░▒█▒█▒▒ + █▓▒ ▓█▒█▒▒▒░░▒▒█▒██ + ██ ▒██ ░█ ░ ▒ ▒██░█▒ + ▒░ ▒█░█ ▒██░▒▓█▒▒ + ▒░ █░█ ▒▓ ▒░░▒█▒░░▒ + ▓░█░█ ███▓░ ▓ █▒░░▒ + ▓░▓█░ ██ ▓██▒ █▒░▓ + ░▒▒▓░ ▓▓░ █ ░░ ░ + ░▓░░▓█▒▓▒▒▒▒▒▒▒██▓▒▒▒▒█ ▓ ░▒ + █░▒░▒ ▓░░▒▒▒▒░▒ █▒▒ ░▒▒ █▓ ░░ + ▒█▒▒█ █ ▒█▒░░█░ ▓▒ + █ ▒█▓█ ▒▓█▓░▓ + ▒▒▒██░▒ █▓█░▓██ + ▒█▓▓ ░█▒▓▓█▓ ░ ░█▓██ + ░██░▒ ▒▒▒▒▒░█ + \ 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 new file mode 100644 index 00000000000..971877651f3 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_21.txt @@ -0,0 +1,17 @@ + + ▒▒█▒█▒▒█▒██▒▒ + ███░░▒▒█░▒░█▓▒░▓██▒ + ▓█▒▒██▒ ░ ░▒░██▒░██ + ██░▓ █ ▒█▓██▓██ + ▓█▓█░ █░▓▒▒ ▒▒▒▒█ + ▓ ▓░ ███▒▓▓ ▒▒▒█ + ░█░░ ▒ ▓░█▓█ ▒▓▒ + ░▒ ▒▓ ░█ ░ ░ + ░ ░ ██▓▓▓▓▓███ ▒░█ ░█ ▓▓ ░ + ░ ░▒ ░▒ ▒█░ ▒ ░█░█ ▓ ▓▓ + ▓ ▓ ░░ █░ ██▒█▓ ▓░ █ + ██ ▓▓▒ ▒█ ▓ + █▒ ▒▓▒ ▒▓▓██ █░ + █▒▒ █ ██▓░░▓▓▒█ ▓░ + ███▓█▒▒▒▒█▒▓██░ + \ 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 new file mode 100644 index 00000000000..2713fd669e2 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_22.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒▒█▒██▒ + ▒█▓█░▓▒▓░▓▒░░▓░█▓██▒ + █▓█▓░▒██░ ░ █▒███▒▒██ + ▓█░██ ██░░░▒█▒ + ▒░░▓█ █▒▓░▒░▓▓▓█░ + ▒░█▓░ █░▓░▓▒▓░ ▒░▒▒░ + ░██▒▓ ░█░▒█▓█ ░░▓░ + ░░▒░░ ░▒░░▒▒ ░▒░ ░ + ░░█ █ █░▒▒▓▓▓▒██▒▒█░▒ ▒█ ▒░▓ + ▒░▒ █▒▒▒█ ▓█ ░▓▓░ ▒█▓▒ ░██ ▓▒▒ + ▒▒▒▒░ ██ ░ ░▓██▒▓▓▓ █░ + ▒█▒▒▒█ ▒██ ░██ + █ █▓ ██▒ ▒▓██ █▒▓ + █▓███ █░▓▒█▓▓▓▒█ ███ + ░ ░▒▓▒▒▒▓▒▒▓▒█░ + \ 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 new file mode 100644 index 00000000000..39a6c556444 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_23.txt @@ -0,0 +1,17 @@ + + ▒██▒▒████▒█▒▒ + ▒▒░█░▒▒█▒▒▒█░▒░█░█▒ + █ █░██▓█░ ░▓█░▒▓░░█ + ▓▓░█▓▓░ ▒▓▓▒░░▓▒ + ▓▓░░▓█ █▓████▓█▒░▒ + █▒░ ▓░ ▒█████▓██░░▒░█ + ░░░ ░ ▓▓▓▓ ▒░░ ░██ + ░▓░ ░ ░ ░█▒▒█ ░ █▓░ + ▒ ▒ ░█░▓▒▒▒▒▒▓▒░▒█░▒ ▒▒ ░ ░░░ + ░▒▒▒░ ▒ ▓░▒ ▒░▒▒█░ ▒▒░ + ▓█░ ░ ░ █░▓▓▒░▒▓▒▓░ + █░░▒░▓ █▓░▒▒▓░ + ▒ ░██▓▒▒ ▒▓ ▓█▓█▓ + ▒▒▒█▓██▒░▒▒▒██ ▓▒██░ + ░ █▒▒░▒▒█▒▒██░ + \ 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 new file mode 100644 index 00000000000..90ccc262f07 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_24.txt @@ -0,0 +1,17 @@ + + ▒░▒▓███▒▒█▒ + █ ▒▓ ░▒▒░▒▒██▒██ + █ █▓▒▓█ ░ ▓░▓█░███ ▒ + ██▓▓█▓░▒█▒░░▓░ ▒█▒░▒▒█ + █ ▓▓▒▓█ ░ ▓▒▒░░░▒░██ + ░█▒█▒░ ███▓ ▓░▓ ▓ ▒ + ░ ░░ █▓▒█▓ ▓▒▒░▒▒░▒ + ░ ▒░░ ░█▒▓▒▒░░▒▓▓░░░ + ░▓ ░▓▓▓▓██░░░██▒██▒░ ░ ░░ + ▒ ▓ █░▓██▓▓██░▓▒▒██░ ░█░ + ▒ █▒░▒█ ░ ▒█▓█▒░▒▓█░ + ▒ ▒██▒ ░ ▓▓▓ + ▒▓█▒░░▓ ▒▒ ▒▓▓▒█ + ▓▓██▒▒ ░░▓▒▒▓░▒▒▓░ + █▓▒██▓▒▒▒▒▒██ + \ 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 new file mode 100644 index 00000000000..d8fd5b45a8f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_25.txt @@ -0,0 +1,17 @@ + + ▒█▒█▓████▒ + █ ███░▒▓▒░█░░█ + ▓░▓▓██ ▓░█▒▒▒░░░▒ + ░██░ ▓ ▒░ ▒░██▒▓ + █▒▒▒█▓█▒▓▓▒░ ░▓▓▒▓█ + ▒█░░░▒██▓▒░▓ ▓░█░▓▓░█ + ░▓░█░ ░▒▒▓▒▒▓░▒▓▒ ░▒░ + ░░░▓░▓ ░▒▒▒▓░▒▒░▒░░▒ + ▒█▒░ ░▒▒▒▒▒▒█░░▒▒░██░▒ + ▓▓ ░▓░█░▒░░▓█▒░▒█▒▓▒░ + ▒░█▓▒░░ ██▓░▒░▓░░ + ░▒ ░▓█▓▒▓██▓▒▓█▓▓░▓ + ▒░▒░▒▒▒█▓▓█▒▓▒░░▓ + ▒▓▓▒▒▒█▒░██ █░█ + ░█ █▒██▒█░█ + \ 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 new file mode 100644 index 00000000000..a4734b4486d --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_26.txt @@ -0,0 +1,17 @@ + + ▒▓███ ██ + ▓█░▓▓▒░█▓░█ + ▓█ ░▓▒░▒ ▒█ + ▓█ █░░░▒░░▒█▓▒ + ░▒█▒░▓░ █▒▓▓░▒▓ + ▒ ░▓▓▓ █▒▒ ▒▒▓ + ░ ██▒░░▓░░▓▓ █ + ▓▓ ▒░░░▒▒▒░░▓░░ + ░ ▓▒█▓█░█▒▒▓▒░░ + ▓▒░▓█░▒▒██▒▒█░ + ░░ ▓░█ ▒█▓░█▒░░ + ▒▒░░▓▒ ▓▓ ░░░ + █ █░▒ ▒░▓░▓█ + ░ █▒▒ █▒██▓ + ▒▓▓▒█░▒▒█ + \ 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 new file mode 100644 index 00000000000..b99e90e6d43 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_27.txt @@ -0,0 +1,17 @@ + + ▓█████ + ░▓▓▓░▓▒ + ▓█░ █░▓█░ + ░░░▒░░▓░░ + ░ ░░▒▓█▒ + ░▒▓▒ ░░░░░ + ▒ ░░▒█░░ + ░ ░░░░▒ ░░ + ░▓ ▓ ░█░░░░ + █▒ ▓ ▒░▒█░░ + ░▓ ▒▒███▓█ + ░░██░░▒▓░ + ░▒▒█▒█▓░▒ + ▒▒▒░▒▒▓▓ + █▒ ▒▒▓ + \ 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 new file mode 100644 index 00000000000..de6db173b46 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_28.txt @@ -0,0 +1,17 @@ + + ▓██▓ + ░█▒░░ + ▒ ▓░░ + ░▓░█░ + ░ ░░ + ░ ▓ ░ + ▒░░ ▒░ + ░▓ ░ + ▓▒ ▒░ + ░░▓▓░░ + ░ ▒░ + ░▒█▒░ + ░▒█░░ + █▒▒▓░ + ░ ▓█░ + \ 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 new file mode 100644 index 00000000000..d7b871c9c33 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_29.txt @@ -0,0 +1,17 @@ + + ██████ + █░█▓ █▒ + ▒█░░ █ ░ + ░░░░▒▒█▓ + ▒ ░ ░ ░ + ░█░░░ ▒▒ + ░▒▒░░░ ▒ + ░░▒░░ + ░░░█░ ░ + ▒░▒░░ ░ + █░░▓░▒ ▒ + ░▓░░░ ▒░ + ░░░░░░▒░ + ░▒░█▓ ░█ + ░░█ ▓█ + \ 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 new file mode 100644 index 00000000000..833b2b3db2e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_3.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓██▒▒▒▒█▒ + ▒██▓▒░░█░ ▒▒░▓▒▒░██▒ + █▓▓█▓░█ ░ ░ ░ ███▓▒░█ + ▓█▓▒░▓██▒ ░▒█ ░░▒ + █▓█░▓▒▓░░█▒▒ ▒▒▒░░▒ + ▓░▒▒▓ ▓█░▒▓▒▒ ░ ▒▒░ + ▒█ ░ ██▒░▒ ░█ ▓█▓░█ + █▓░█░ █▓░ ▓▒░ ░▒░▒░ + ▓ █░ ▓░██░░█▓░▒██▒▒▒██▒░▒ ▓░ + █▒▓▒█ ▓▓█▓▓▓░ ░█░▒▒█ ▒▓█▓▒░░▒░░ + █▒░ ░ ░░██ ███ ███▓▓▓█▓ + ██░ ▒█ ░ ▓▒█▒▓▓ + ▒▒▓▓█▒█ ██▓▓ █░█ + ▒▒██▒██▒▒▓▒▓█▓▒█▓░▒█ + ░███▒▓░▒▒▒▒░▓▓▒ + \ 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 new file mode 100644 index 00000000000..9c27cf67d0f --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_30.txt @@ -0,0 +1,17 @@ + + ▒▓ ████ + ▒▓▓░░▒██▒▒ + █▒░█▒▒░██▒ + ░░▒░▓░▒▒░▒ ▒█ + ▒█░░░▒░█░█ ░ + ░█░▒█ █░░░░▓░ + ▒▓░░░▒▒ ▒▓▒░ ▒░ + ░ ██▒░█░ ░▓ ░ + ░▒ ▒░▒░▒▓░█ ░ + ░░▒░▒▒░░ ██ ░ + ▒░░▓▒▒█░░░█░░ + ░█▓▓█▓█▒░░ ░ + ▒░▒░░▓█░░█░▓ + █▒██▒▒▓░█▓█ + ▒▓▓░▒▒▒▓█ + ░░░░ \ 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 new file mode 100644 index 00000000000..c787451d71c --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_31.txt @@ -0,0 +1,17 @@ + + ▒▓▓████▒█ + ▒██▓██▒ █▒███ + █░▒▓▓█▒▒░▓ ░▒█▒ + █░▓█▒▒█▓▒█▒▒░▒░░▒ + ▒░░░░█▓█▒▒█ ▒░▓▒▒ + ▓░▒░░▒░█ ▒▓██▓▓░█ ░ + ▓░░ ░▒█░▒▓▒▓▓█░█░▓░ + ▒▒█ ░░ ░▒ ░▒ ░░▒▓░ + ░▒█▒░█▒░░░▓█░░░▒ ░ + ░░░▓▓░░▒▒▒▒▒░▒░░ █ + ▒█▒▓█░█ ▓███░▓░█░▒ + ░░░▒▒▒█ ▒▒█ ░ + ▓░█▒▒ █ ▓ ░█░▓░ + ▓░▒░▓▒░░█░ █░░ + █ ▒░▒██▓▓▓█ + ░░░░ \ 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 new file mode 100644 index 00000000000..e5e7adf64d4 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_32.txt @@ -0,0 +1,17 @@ + + █████▓▓░█▒ + ▓█░██░▒░░██░░█ + ▓▒█▒▒██▒▓▓░█░█▒███ + █▓▓░▒█░▓▓ ▓ █▒▒░██ █ + ▓▓░█░█▒██░▓ █░█░▒▓▒█▒█ + ▒▓▒▒█▒█░░▓░░█▒ ░█▓ █ + █░ ▓█░█▒░░██░█▒░▓▒▓▓░█▒ + ░░░█▒ ▒░░ ▓█░▓▓▒ ▒░ ░ + ▒░░▓▒ █▒░ ▒▒░███░░░▒░ ▒░ + █ ▒░░█▒█▒▒▒▒▒▒░░█░▓░▓▒ + █▒█░░▓ ░█ ███▒▓▓▓▓▓▓ + ▒█░▒▒▒ █▒░▓█░ + ███░░░█▒ ▒▓▒░▓ █ + ▒▓▒ ░█░▓▒█░▒█ ▒▓ + ░▓▒▒▒██▓█▒ + ░░░ \ 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 new file mode 100644 index 00000000000..31a607b29cb --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_33.txt @@ -0,0 +1,17 @@ + + ▒██▒█▒█▒░▓▒ + ▒██░░▒█▒░▓░▓░█░█▓ + ▒▓▒░████▒ ░ █▓░░█ █ + █▒▓░▓▒░█▒ █░░▒▒█ + ▒▓░▓░░░▓▒▒▒ ░█▒▒▒ + ▓▓█ ▒▒▒▒░▒█ ▓▒▓▒▒ + ░░█ ▒██░▒░▒ ░█░░ + █░██ ███▒▓▒█ ▒ ░█ + ░░░ ░ █░ ▓████▓▒▒█░░█▓▒░▒░ + ▒▓░█ ▓▓█▓░░░▒▒▒▒▒░░█▒▒▒░░▓ + ▒▒▒█ ░▓░▓ ▓ ███ ░░█▓▒░ + ▒█▒██ █ ▓▓▓▓▒▓ + █▒ ███▓█ ▒█░█▓█▒█ + ▒░ █▒█░█▓█▒ ▓█▒█░█ + ▒▒██▒▒▒▒██▓▓ + ░░░░ \ 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 new file mode 100644 index 00000000000..db99cb73d61 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_34.txt @@ -0,0 +1,17 @@ + + ▒█▒████▒░█▒ + ▒███▓▒▓░ ░██▒██▓█▒▒ + ▒▓▓█░█ ▓░█░ ░▒▒▒█ ███ + █▓▒░█▒▓█▒ █░██▒▒ + ▓▓░▒▓▓░ ░ █ ▒▒█▒▒ + █▓▒░░▓ ▒▒ ░▒█▒ ▒█▒░▒ + ░█▒░▒ █▒▒█░▒▒ ░▓░▒ + ▒░▒ ▓ ░█▒░▓ ░ ▓ ▒▒ + ██▓▓ ▓▒▓▓ ▒▒▒██████░▒▒ ░▒░ + ░░▒█▓██▒ ▓▓█░░░▒░▓▒▒▒█▓▒░░░░▒ + ▓▒▒█ ░▒░█▒ ██░░░░▒ █▓█▒░█ + ▓█▒▓▒▒▒ ▓▓▓░▓█ + ▒█░░█▒▓█ ▒█▒ ▒▓█░ + ▓▒▓░ ░██▓██▒█▒█░██▓█ + ░▒▓▒▒▒▒▒▒▓▒█▒▒ + ░░░ \ 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 new file mode 100644 index 00000000000..814188563de --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_35.txt @@ -0,0 +1,17 @@ + + ▒██▓▒███▒██▒ + ██▒█▓░███ ░█░▓ ░█▒▒ + ▒▓▓░▓██░▒█ ░ ░ █▒█▓ ░██ + █▓▓█▓█▓█▒ ██▒▒░▒ + ▓▓░░▓▓▒ ▒██ ░▒█░█ + ▓▓▓▓█░ █░▒ ▓▓█▒ ░▒▒░ + ▒ ▓▓ ▒▒ ██▒▓ ░▒▒▒ + ░░░▓ ▓▒▒▓▓█ ▓ ▓ + ▓ █▒ █░░▓▓ ▓░▒▒▒▓▒▒█░░ ░░▒█ + ░█▒▓█ ▓▓▓ ██▓░▓ ▒█▒▒▒▒▓ ░▓█ ░█ + ▓░▒██▓▒▒░▓▒░ ░ ▒▒▒▒█▒▒█▓▓▒█░ + ▓▒▒▓░ ▒▓█ █▒ + ▒▓░▒▓█▓█ █▓▓▒███ + ▒▒ ░█░▓▓░░█░▓▓█ ▒▓▓ + ▒░▓▒▒▒▓▒▒███ ▒ + \ 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 new file mode 100644 index 00000000000..cde83b56f41 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_36.txt @@ -0,0 +1,17 @@ + + ▒▒█▒████▒██▒ + ▒▒ ▒█▓▓▓█▒█▓██ ███▒ + █▒█▒███▓█ ░░ ░ █░██░██░█ + ▒░ ██▒▒▒▒ ██░▒ ░ + █▓▒▓▒█░▒░▒█▓ ▒▒▓█ + ▓ █▓░ █▒ ░▓█ ▒▒█ + ░ ▓ ░ ▒ ▒▒ ░▒░█ + ░░▒░ ▒▒ ▒▓▓ ▒░ ░ + ░█ ░ ▓▓ ██ ████▒█████▒ ░▒░░ + ▒█░▒ █░▒▒▓░▓ ░░▒▒▒▒▒▒▒░░ ▒▓█░ + █ █░▒ █▒█▓▒ ██▒▒▒▒▒ ░█ ▓ + ██ ▒▓▓ █▓░ ▓ + ▒▓░░█░█ ███ ▓█░ + ██▒ ██▒▒▓░▒█░▓ ▓ █▓██ + ░██▓░▒██▒██████ + \ 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 new file mode 100644 index 00000000000..7ad27d16e74 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_4.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓█▒▒█▒██▒ + ▒▓ ██░▓ ░▒▒▓█▓░ ▓██▒ + ██▒░░░██ ░ ░▒░▒▒█░▓▒▒▒ + ▓▓░█░ ▓██ ░██▒█▒ + ▓░▓▒░▒░▒▓▒░█ ▒ ▒░█▒ + ▓░░▒░ █▒░░▓█▒ █ ▒▒░█ + ▒░▓░ ███▒█ ░█ █ ▓░ + ░▓▒ █░▓█▒░░ ░░░ + ▒░ ░▒ ▓░▓ ▒▓▓█░███▒▒▒▒██ ░░█ + ░▒▓ ░ █▓▓▓█▒░░▒▒░█▓▒█▓▓▒▓░▓▓ ░ + ░░▓█▒█▒▒█▒▓ ████████▒▓░░░░ + █░▒ ░▒░ █▒▓▓███ + ▒▒█▓▒ █▒ ▒▓▒██▓░▓ + ░░░▒▒██▒▓▓▒▓██▒██▒░█░ + █▒▒░▓░▒▒▒▒▒▓▓█░ + \ 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 new file mode 100644 index 00000000000..24f98439548 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_5.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒▒██▒ + ▒█ █▓█▓░░█░▒█▓▒░ ██ + █▒▓▒█░█ ░ ▒▒░█▒ ███ + █░▓░▓░▓▒█ ▓▒░░░░▒ + █▒▓█▓▒▒█░▒▒█ ░ ▒░▒░▒ + ░░░░▓ ▒▒░▒▓▓░▒ █▓░░ + ░▓░ █ ░▒▒░▒ ░█ ██░█░█ + ░▓░▒ █▒▒░▓▒░ █░▒░ + ░█░▒█ ▓▒░ █░█▒▒░█░▒▒▒██▒ ░▓░ + ▒▒░▒██▓██ ░ ▓▓▒▒▒█▒▓█▓░▓█░░ + ▒█░░█░█▒▒▓█░ ██ █░▓░▒▓ + ▒▒█▓▒▒ ░ ▓▒▓██▒ + ▒▓█▒░▒█▒ ▒▒████▓█ + ▒░█░███▒▓░▒▒██▒█▒░▓█ + ▒▓█▒█ ▒▒▒▓▒███░ + \ 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 new file mode 100644 index 00000000000..fe185a75737 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_6.txt @@ -0,0 +1,17 @@ + + ▒▓▒▓▓█▒▒██▒▒ + █▒▓▓█░▒██░██▓▒███▒ + ███░░░█ ░ ░▓▒███▓▒▒ + ▓█░█░█▒▒█ ▒█░░░░█ + █▒░░░█▒▒██▒ ▓▒▒░▒█ + ▓▓▓░▓░▒█▓░▒▒░█ ▓▒▒▓░ + ▒ █░░ ▒▒░▓▒▒ ▒█░▒░ + ░ ░░░ ▒░▒░▓░░ ░█▒░░ + ▒▓░▓░ ▓█░░█▓▓█▒░█░▒▒██▒▓▒▓░ + ░░▒█▓▒▒▒▓█ ░▓▒██░░█▓▒▒▒░█░▒ + ▓ ░ ▓░░░▓▓ █ ██ ░▒▒▓░ + █ ▓ ▓█░ █▓▒▓▓░░ + ▓░▒▒███ ▒█▒▒▓███ + ░ ░██ █ ▓░▒▒████ ▓▓█ + ▒▓▓███▒▒▒░▒███ + \ 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 new file mode 100644 index 00000000000..7441f97e96e --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_7.txt @@ -0,0 +1,17 @@ + + ▒▓░▓██▒▒██▒ + ██░█▒░███▒▒▒▓ ░██ + █ █░░░█░ ░▒░░ █▓▒██ + ▒▒░░░░▓█ ▒░▒█░▓█ + ░█░█░░▒░▓▒█ ▓ █░░▒ + ░ ▓░░ ░█▒▓░▒ █▓░░░ + ░▒ ░ ▒▒░▒░▒░ ██▒░░ + ▒ ▓░░ ▒█▓░█░░ █ ░░░ + ▓ ░█ █ ▒▓░▒▓░░▓▓▒░░▒▓█▒░░ + ░██░░▒▓░░▓█░▓▒░░▒▒█▒█▓▒░▒░ + ▒ ▒▒▓█░█▒▓ ██████ ▒▓░░ + █▒ ▓▒▓▒░ █ ▓▓▓▓█ + █▓██▒▒▒▒ █▒░██▓██ + ▒▒█▒░█▒▓░▒▒▒██░██▓ + ░█ ░▓░▒▒█▒▓██ + \ 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 new file mode 100644 index 00000000000..ea88b095382 --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_8.txt @@ -0,0 +1,17 @@ + + ▒▒█▒▓██▒██▒ + █ █▓░░░█▒▒ ░ █ + ▒░▒█░▓▓█ █ ░▓░█▒█▒█ + ▒█▒█▓░██░ █ ▒▒░░▒ + █ ▓░▓█▒░▓▒ ▓█▒░░█ + ░██░▒▒▒▒▒░▒█ ▒█░░░ + ░█░░░ █▒▓▒░░░ ░▒░▓░█ + ▒█░░▓ ░█▒▓░██▓ ▓░▓░░ + ▒ ▒░░▒▒ ▓█▒░░▓█████▒░░░ + ▒█▓▒▒░ █░█░░▓░▒▒▒░░▒█ + ▓▓▒▒░▒░░░▓█▒█▒█ ▒█ ▓▒░ + ██ ░▒░░░ ▓█▓▓▓█ + █▒▒█▒▒▒▒ ▒▓▒▒░█▓█ + ▓▓█░██ ▓▓██▓▓▒█░░ + ░░▒██▒░▒██▓▒░ + ░░ \ 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 new file mode 100644 index 00000000000..9066ba1beda --- /dev/null +++ b/codex-rs/tui2/frames/blocks/frame_9.txt @@ -0,0 +1,17 @@ + + ▓▒▒█▓██▒█ + ▓█▒▓░░█ ▒ ▒▓▒▒ + ▓ █░░▓█▒▒▒▓ ▒▒░█ + ░░▓▓▒▒ ▒▒█░▒▒░██ + ▓█ ▓▒█ ░██ █▓██▓█░░ + ░ ░░░ ▒░▒▓▒▒ ░█░█░░░ + ░ ░█▒░██░▒▒█ ▓█▓ ░░░ + ░ ░▓▒█▒░░░▒▓▒▒▒░ ░░ + █░ ▓░ ░░░░█░░█░░░ + ░▒░░░▒█░▒░▒░░░░▒▒░░░ + ░▒▓▒▒░▓ ████░░ ▓▒░ + ▒░░░▒█░ █▓ ▒▓░░ + ▒█▒░▒▒ ▓▓▒▓░▓█ + ▒▓ ▒▒░█▓█▒▓▓█░░ + █▓▒ █▒▒░▓█▓ + \ 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 new file mode 100644 index 00000000000..63249f42421 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_1.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..fe5e51b9845 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_10.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..48e507a84a1 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_11.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..29de69516a3 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_12.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..67fe336a137 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_13.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f8d32cd6d19 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_14.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..2e14341237a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_15.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c90ce92cb6d --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_16.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..e1f2bb6d96c --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_17.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..be64251770d --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_18.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..89041571213 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_19.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..a3c0663db46 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_2.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..cea5393f758 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_20.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..efa6d610d9f --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_21.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..91c9c2ecaae --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_22.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..5b5f1be139d --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_23.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c0269d8eda6 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_24.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..5b040665d0b --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_25.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..1592c09e8cf --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_26.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..5279157c040 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_27.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ea695865f4a --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_28.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..328d426a415 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_29.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..3e9206577af --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_3.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..b9da98c5c37 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_30.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..baef07474cb --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_31.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c0997d9a140 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_32.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..cd8691c1502 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_33.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ef8eabf7dc0 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_34.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..1c53d2373f2 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_35.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..4928a2a9d07 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_36.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..a5ae50eeae4 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_4.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..47abf7a0af6 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_5.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ba04c52772f --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_6.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f7dd0de9b60 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_7.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..e3f93702f72 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_8.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..210e417d435 --- /dev/null +++ b/codex-rs/tui2/frames/codex/frame_9.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..64a140d2b9c --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_1.txt @@ -0,0 +1,17 @@ +                                       +             _._:=++==+,_              +         _=,/*\+/+\=||=_ _"+_          +       ,|*|+**"^`   `"*`"~=~||+        +      ;*_\*',,_            /*|;|,      +     \^;/'^|\`\\            ".|\\,     +    ~* +`  |*/;||,           '.\||,    +   +^"-*    '\|*/"|_          ! |/|    +   ||_|`     ,//|;|*            "`|    +   |=~'`    ;||^\|".~++++++_+, =" |    +    _~;*  _;+` /* |"|___.:,,,|/,/,|    +    \^_"^ ^\,./`   `^*''* ^*"/,;_/     +     *^, ", `              ,'/*_|      +       ^\,`\+_          _=_+|_+"       +         ^*,\_!*+:;=;;.=*+_,|*         +           `*"*|~~___,_;+*"            +                                       \ 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 new file mode 100644 index 00000000000..9d45417346b --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_10.txt @@ -0,0 +1,17 @@ +                                       +              _+***\++_                +             *'`+*+\~/_*,              +            ^_,||/~~-~+\,,             +           |__/\|;_.\,''\\,            +           / ;||"|^  /_/|/            +          |` '|*~//\   `_"|            +          \  ~*"*||~|*   |/,           +          "  ||\+/+||-_ .\||           +          "  ~\ \\|;~~+\+;||           +          |  ,|\,|_/_*___|*`           +           , "|||||""!\,"\|`           +           \`',\,*"  "",//            +            |' |||~*,:,/|/`            +             ;`**/|+;_!//'             +              *, _*\_,;*               +                                       \ 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 new file mode 100644 index 00000000000..769e5ae76d7 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_11.txt @@ -0,0 +1,17 @@ +                                       +               ,****++_                +              /" ;|||\\,               +             /"__||;\*/\,              +             |__||=;,_=//              +            _".;\|+\';_||,             +            |+`||+_;;|_/||             +            ** ,||||||_|=\             +            |  ||/||\/ |"|             +            /  '|/||*/+|_|             +            ** _|/=,"/|_|^             +            '`- ||||=/|\\|             +             \_-/|_*/**;|`             +             !_ *|\\^_|;"              +              \+!*||,_/*`              +               \_ '*+_+`               +                                       \ 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 new file mode 100644 index 00000000000..50cfd73302d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_12.txt @@ -0,0 +1,17 @@ +                                       +                +***+.                 +               ,=`_/|,\                +               "  |/\+,                +               /+~||=="|               +              | '~|||./|               +              |'..*^"_|"               +              |   ~/\||\               +              |   /+\||"               +              *, ~/||+|~               +              |   /|*;*_               +              |.  |"=**/               +               ,  *|!_,|               +               / **|,*\|               +               '^/",|\|`               +                \ '~\./                +                                       \ 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 new file mode 100644 index 00000000000..04ed71335c1 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_13.txt @@ -0,0 +1,17 @@ +                                       +                 /***,                 +                 |__||                 +                 |`_|"                 +                 |**|_                 +                 *  ||                 +                 ":-||                 +                 ,  ||                 +                 +  "|                 +                /+  _~.                +                |"  +=|                +                '`.. ~`                +                 |___|                 +                 |+,|_                 +                 *__|=                 +                 , ."=                 +                                       \ 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 new file mode 100644 index 00000000000..66e91f7187b --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_14.txt @@ -0,0 +1,17 @@ +                                       +                 +***;                 +                 ,/__.\                +                |_||. |                +                ||/|"^~,               +                |||\   |               +                ~*||  '|               +                |||| . *               +                ||\|`  \               +                |||~   "               +                "^//  ;/               +                \|"",.,|               +                |*~|___|               +                /!"|===`               +                |\/*__/                +                 _* '=/                +                                       \ 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 new file mode 100644 index 00000000000..9d8132e3c41 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_15.txt @@ -0,0 +1,17 @@ +                                       +                 ++***~_               +                `,=||^:*,              +               //|*=\|"*|              +               |/` //,.__|             +              ||="=\||/"^|             +              \||-||//|  "             +              ||   ||||~~,|            +              ||/~|+||| '-|            +              ||+,,*|||_.:|            +              |_|;/|\~*. .|            +              |/||||_| ` ;             +              |**.^~|\-  =             +              '|\, ///` ;`             +               |^||\\.+\/              +                \^*^___/               +                   ``                  \ 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 new file mode 100644 index 00000000000..7217fe58b8e --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_16.txt @@ -0,0 +1,17 @@ +                                       +                _=+"**+~_              +               /^||*||\|=\             +              |//"\/=\|| '\            +             /// ;' \|\||**|           +             ||;_ =||*/|`\           +            |||*|  /|= !| ~.|          +            \\|  ,||||/*", |          +            |/; |`||/|||"; `|          +            \\|~|+~/^||"*+  /          +            *"__,==\*|._| ,_|          +            |||+""/*\|;";.~|`          +             ||* |   `//,  /           +             \|*  |  /,/_,|            +              \|~"_*~//+_|             +               ':._=:__;*              +                                       \ 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 new file mode 100644 index 00000000000..0d873df7518 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_17.txt @@ -0,0 +1,17 @@ +                                       +                ,=+++;;~,_             +             _;**|~~*=*|,"^,           +            ,*\/_==`+,"|||_"\          +           /|/_/|"   |;\~||=\         +           |/_ ~     "/\=\//  ,        +          `=*,/`   ,:/| /,=/|./        +          *!;/|   ,//|_ *"||/=|        +          -"=|!   !//||/ ,||=;*        +          ,/*/\==+~\_|\^:\||| |        +           |"_;__|/*\/||\!\+'+\        +          \\\/"""****\_|*//\ \'        +           \||_*       `/||` ;         +            _\\!*\_   ,',/^_/          +             , ~*+=\+`_;*:|'           +              `+;/_,+~*_+*             +                   `                   \ 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 new file mode 100644 index 00000000000..a474a4f3d03 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_18.txt @@ -0,0 +1,17 @@ +                                       +               _==+==+;~,_             +            _+"_;,++~__,"+;;_          +          _/:|*"*=" "._"+//\ *         +         ,||*.,^" _/=~\;\\\,       +        _\// /|   _\/~/|_\;\\_       +        /\| ,, _/,*,|'-/^/`/~!      +        ||\:/     +/*/|"_/"*|=|=,      +        "\-~|     ^\"||;^   |;|"       +       \"" ,\==;=;+~|,|*/\, |*|`       +        _\\|*\* ~|/__\_~||_;~`\ ,      +        |/|\`""*****` \__/|/*/`-`      +         \\!,\,         ``*"/*_'       +          \^*+^^.      _*^_/\,`        +           '|.**++===^'*_/_,*          +             "~|_/__,.+";+"            +                    `                  \ 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 new file mode 100644 index 00000000000..e83b78bd3ba --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_19.txt @@ -0,0 +1,17 @@ +                                       +              __==+~+==~._             +           _+|||\/===_*_,|*,,          +         ,*;;/"|+*`    `*:,`*;\        +        /;|*,^`         _==+,^|*,      +       ||/`/`         ,|^"/"|\ |\,     +      |\/*'         _|*~/!./ '.*|      +      ^=|~'        /*^/|+,`   /:/+|    +      ||||         |;"\|",    | |||    +      |*|\,=;;===~~~|+*;*|;   \ "||    +      ^;/,|=*/^*+\,`, ^_ :\_\,/.|_     +      '\"/+|"""""**"   ^\_/|// //'     +       \\^*_\             `//_//'      +        '!_\~~*,        ,;=./|;`       +          '".|*/~+__=;/*" ;*~'         +             "~._:-'_,.^*-^            +                                       \ 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 new file mode 100644 index 00000000000..ac205dd4a51 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_2.txt @@ -0,0 +1,17 @@ +                                       +             _._:=+===+,_              +         _+,/*;+||~=~|=_'|*+_          +       ,|*|\**~*``  `"*=*/||;|,        +     _|/_/*',,_           -,\|/|,      +     ,^"/*^|\_\\_           ^,|\|,     +    '/+"`  |*\+/\+           \\|*|     +   ,|'*|    ^/|;|_|,          /"|"|    +   |" \`     ,|*||;*          |'/.|    +   *""=|    ;|^^_|".~++++++++,|_|~*    +    _='|  /||' /* |=\____..__|+/!|!    +    \\\ * *\..|'   `"*******"|,*||     +     '\_./, `              ,+;/,*      +       .\__|+,          ,=_+!_|"       +        `~^;__"*+:;=;;.=*`_||*         +           `*+*+~~____~;/*"            +                                       \ 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 new file mode 100644 index 00000000000..bff8cc065f9 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_20.txt @@ -0,0 +1,17 @@ +                                       +              __+=~~=+=,__             +          ,;=";,_,===||_^*\+,          +        ,,"_+*"~*"    `"^"\+*|+_       +      _|"_*|*           _,+|\/*\\      +     _| ,|*           _/!_||^*\||\     +     /`,|'           +*';|"/  '\~|\    +    ;|;+|          ,* .+*^     ,\|/    +    |_^/`          "";:|",     |~"|    +    |/||;,=;======_,';^^\\*    / |\    +    '|^|_" /``____|\  *\\"|=\ ,/ ||    +     \,^\'"""""""*"     \,=||,| /\     +      * ^*/,               _/*.|/      +        \_^*,~_          ,;*`;*'       +          ^,-."~+^;;,:"~"`,/*'         +            `*+~_!=____|*""            +                                       \ 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 new file mode 100644 index 00000000000..b23aadbc7c7 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_21.txt @@ -0,0 +1,17 @@ +                                       +             __,=+==+=+,__             +          ,+*``__+~=~+;_`-*+_          +        ;*^_+*^"`     `^~*+_`*,        +      ,*|;"'             _,;*,;*,      +     /,/*`             ,|/_\ \\_^,     +    /"/|             ,**_//    \\^,    +    |'|`           _!/`,/'     \;\"    +     `\            \; "|,       | |    +    | |  ,+;;;;;+++  ^|,"|,    ;/ |    +    | ~\ |_      _,|   ^"`*|,  /"./    +     ; ". ``"""  '`     '*_,; /` ,     +     '+ :;_                 _*" /      +       *_  ^-_          _.;*' ,`       +         *=_ *"*+:~~;:=*""  -`         +            *++;+____,_;+*`            +                                       \ 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 new file mode 100644 index 00000000000..ccc8480d8b1 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_22.txt @@ -0,0 +1,17 @@ +                                       +             _,+=+==+=+,_              +         _+/*|._/|/\||;|+/*+_          +       ,;*;~\**`    `"*\**+__+,        +      ;*~**"            ,,|||_*\       +     \|~/'            ,_/|=|./;'|      +    \|*/`           ,~/|;^/` \|\=|     +   |+*\/           ~+|\*/'    ~|/|     +   ||=|`           |\|~^\     |^|"|    +   ||,", +~==;;;=++_\*|_!\,   _|;"!    +   ^|= *_\\,!/,"~//|  \*;_"|,,!/\=     +    \\\_| """""**"`    `:*+_///",`     +     \*\_\,               _*,"|,'      +      '"+; ++_         _.*,"*_/        +        ':*+,"*~;=+;;/=*""',*          +           "~"~_;___-=_.=*`            +                                       \ 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 new file mode 100644 index 00000000000..406ced01b08 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_23.txt @@ -0,0 +1,17 @@ +                                       +            _,+_=+*++=+__              +         _=|+|\_,_==,|_|*|+_           +       ,"+|',;*`    "~:+|\;||,         +      /;|*;/`          _;;\||;\        +     //|`/'          ,/,,'*/*\|\       +    ,\|"/`         _***''/*'|~\|,      +    `||"|         /:/. _|`  "!|'*      +    ~/| |         |"|,_\,   | */|      +    ^"\ |+~;=====;=|_*|_"\_ | |~|      +    |\\_|"="       /`\ \|_\,~ \_|      +     /'| | `"""""""   '`/;=|_/^/`      +      ,||_|.             ,/|\^/`       +       \ |,'/__       _.";*/+/         +         \=\+;*+\~==_++"-_+*`          +           "~!*=\~__+__**`             +                                       \ 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 new file mode 100644 index 00000000000..73f56393902 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_24.txt @@ -0,0 +1,17 @@ +                                       +             _~_;++*==,_               +          ,"_/"|__~==+,_'+             +        ,"';\/+"~ .`:*~**, \           +       ,'//,/|=,\`~/`!_*\|\_'          +      , //\/,    `""/\_|``\|,'         +      ~,\+\`       *,,/!.|;!/"\        +     |  |~!      ,/_,/"/^\|\^|^        +     | \||       |,=/\_|~_/.`||        +     |. |;;;:++~~~++_*,_| |  ||        +      _ / !*|/,,;;,,|.^\+*| |*|        +      \ '\|\*""""""` \,.*\|=/,`        +       \ \,*\           |!"/;/         +        \.*\`|.      _="_/:_*          +          .-*,\^"~~:==;|^_/`           +            *:_*+;\__==+*              +                                       \ 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 new file mode 100644 index 00000000000..6fb0cbc16cf --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_25.txt @@ -0,0 +1,17 @@ +                                       +             _+=*;++++_                +           ,!*,*`_;\|*||,              +          /|//,,".|+\=\|||_            +         |+*| /! =| "\|,*\/            +        ,__\,/'^;/_|" |//\/*           +        \,~|~_*+.^|: /|,|//|,          +        |/|*| |__/\_/|\/_"|=|          +        |||/|."!~_=\/|\_~=||\          +       ^+\|"|__====+~|\\|+*|\          +        /-"|/|,|_||;*_|\*=/\|          +        \~*/\|`"""""'+/|\|/|`          +         |_"|;+;\-,*:_/,//|/           +          ^`^|_\_*;/,^/_||/            +           \.;\\_*=|**!*|*             +             ~,"*\+,_+|*               +                                       \ 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 new file mode 100644 index 00000000000..8bd6052839d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_26.txt @@ -0,0 +1,17 @@ +                                       +              _;***"+,                 +             /*|;/\|+;|,               +            /*""|;\|\""\,              +           ;*",||~_||_+/^              +           |_,\|.~"*\/;|\;             +           \ "|/:/"*_\"\\/             +          |!  *'=||/||;;"+             +          /-  \|||^^=||/||             +          |  .\*;+~+==/\||             +          ! ._|/,|__,*\\*|             +           |`"/|*"\,/`+\||             +           \_~|/\   //"||`             +            *  *|\ ^`/|/,              +             |"*\\"*_**;               +              \/:^*~_\*                +                                       \ 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 new file mode 100644 index 00000000000..e8630695b8d --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_27.txt @@ -0,0 +1,17 @@ +                                       +                ;***+,                 +               |;:/|/\                 +              ;'` *|/'|                +              |~~^||;|~                +              |  `|_-'=                +              ~_._"`|||`               +              = "||_*||!               +             |  `||~="||               +             |. .!|+||||               +             '= ."_|_*||               +              |- _^**+/'               +              ||++||_/|                +              |==+=,/|^                +               \__|\=//                +               '\" ^\/                 +                                       \ 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 new file mode 100644 index 00000000000..3313d8b9bf7 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_28.txt @@ -0,0 +1,17 @@ +                                       +                 /**;                  +                 |+_|`                 +                 = ;|`                 +                 |-`*|                 +                 |  ~|                 +                 | -"|                 +                ^|~ _|                 +                 |-""|                 +                /\  _|                 +                ||.:~|                 +                 |  _|                 +                 |=+=|                 +                 |=*||                 +                 *__/|                 +                 ~ .+|                 +                                       \ 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 new file mode 100644 index 00000000000..2ae088f1b90 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_29.txt @@ -0,0 +1,17 @@ +                                       +                 +****,                +                ,|*/!*\                +                ^,|| '"|               +                ||||^\,/               +                \ ~"|  |               +                |,~|| __               +                |\\||| ^               +                ||=~|                  +                ||~*|   `              +                _|=||   `              +                *||/|^ =               +                |/~~| _|               +                ~|||`~_|               +                |=|*/"|'               +                 ~|* .,                +                                       \ 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 new file mode 100644 index 00000000000..727e25a8e89 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_3.txt @@ -0,0 +1,17 @@ +                                       +             _.=;++====,_              +         _+,/\||+|"==|;_^|*+_          +       ,;/*;|*""`   `"~!**+/^|,        +      /+/\|;,+_          `=*!||\       +     '/*|/^/||*\_           \^\||_     +    /|\\/  .,|\;\\           | \\|     +    =* |    '*\|_"|,          .*/|,    +   ,-|,|     ,-|"/\|          ~_|=|    +    -"'|    ;|*+~|*-~=++___++_~^";|    +    ,\:\, ./*/;;| |*|__,!=.,;\`|\|`    +    '^| | "||+,"   "***"""**,///,/     +     '*~ \+ `              /^+^//      +       =^//*\,          ,+;/",|'       +         =^+,_**^=;=;,:=*;`_+"         +           `*+*_:~____~;/^"            +                                       \ 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 new file mode 100644 index 00000000000..99eeebce339 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_30.txt @@ -0,0 +1,17 @@ +                                       +                 _;"**+,               +               _/;||\*'=\              +               "'^|,\\|+,\             +              ||\|/|_\|\ \,            +              =*||`\|,|,  |            +             |*|^+  *||||.|            +             \/|||\_ \/\| =`           +             "| '+=~,|"|-  `           +             "|_"\~=~\/|,  `           +             "||\|__~|!+,  `           +             !\||;\_*~||+~|            +              |*//,/*\||" |            +              \|\||/*~|,~/             +               ,^,+=^/|,/'             +                \-.|^__;'              +                  ````                 \ 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 new file mode 100644 index 00000000000..8d9adf28b24 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_31.txt @@ -0,0 +1,17 @@ +                                       +                _.:*+*+=,              +              _+*;+,_"+\'*,            +             ,|\//,=_`."|_*\           +            ,~/+__*;_,\\|\~|\          +            ^||||+-*\_,"\|/__          +           ;|^`~_|'"\;*,./|,"|         +           /|| |^*|\.=/;*|*|/|         +           \\, |~"|\ |^""|~\.|         +           |\,_|'^~|~/+~~|_  |         +           "||~//||___\_|\|| *         +           ^*_/+|, /***`/|'~_!         +            |||\\\,     _=* |          +             ;|*=_!,  . |*`/`          +              :|\|/_|`,|",|`           +               '"=~_+*/.;*             +                   ````                \ 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 new file mode 100644 index 00000000000..4175a7a66ef --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_32.txt @@ -0,0 +1,17 @@ +                                       +                ,++++;;~,_             +              ;*~**~\||**|~,           +            /^*=^,+^:-`*|*\'*,         +           '//|_,`;- - ,_\|+,!,        +          //|,|*\'*|; '`,~\/\*\,       +          \/\\,\*|`:||+_   |+/ *       +         *|";,`'\||,,|,=`/_//|'_       +         |||,_  "_||"/'|;-_"\|""`      +         \||-_ '_|"__|+++~~~=|"=`      +         '""_`|*\'\_____||*|;~-_       +           ,\*||/ |*""***^;///./       +           \,|^\\        ,\|/'~        +           '**||~+_    _/_|/ ,         +             \-\"~+|;=*`_+"_/          +               ~;=__,+/*_""            +                   ```                 \ 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 new file mode 100644 index 00000000000..dbd9568018a --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_33.txt @@ -0,0 +1,17 @@ +                                       +               _,+=+=+=~._             +            _+*||\*=~:|-|*|*.          +          _/\|+*+,="`  "*/||+",        +         ,\/|/_|,_        *||\_*       +        _.|/`||/\^\         |+\\^      +        //,   \_\\`\,        /\/_\     +        ||+ !  \,*|_|\       |*||      +        *|,,   ,''\/=,       \"|*      +        ||| | ,` /*,,,;==+~~+/_|\~     +        ^/|'"/:,/`~|_____||*^^^~|.     +        \=\, |/|/ / """"*** ||,/^`     +         \+\*,",           //;/=/      +          *\"*,*;,      _+|,/*\'       +            \~"*\+|,;+_";,\*~*         +              "==+,___^+*;-"           +                   ````                \ 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 new file mode 100644 index 00000000000..7fc67a92dbc --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_34.txt @@ -0,0 +1,17 @@ +                                       +               _,=++++=~,_             +           _+*+/\;|"~+*=**;,=_         +         _//,|*":~*`   `^\\,"**,       +        ,/_|*_/,_          *|,*\\      +       //|\-/|!|"!,          \\*\^     +      ,/\|`/ \\"|\*\          \,\|\    +      |*^~_   '\_*|_^          |;~_    +      \|=":    |*_|/"|         / _\    +      ,'/."   /\//"_==++++++~__" ~^`   +      ||\,/,,^"//'~||_~.___,/_||`|\    +       /_\, |\|*^!  "**````^ ,/,\|'    +        /,\;=\_             /;.|/'     +         \+`|+\;,        _+="_/,`      +           -\/~"|+,;,+=*=*`+*:'        +             "~\;=_____;=*=^           +                   ```                 \ 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 new file mode 100644 index 00000000000..570f34f0de5 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_35.txt @@ -0,0 +1,17 @@ +                                       +              _,+;=+++=+,_             +           ,*=*.~**+"|*~:"~*=_         +        _//|;+*|^*"`  `"*=*."|*,       +       ,//+/,;,_            *+_\|_     +      //~|//\ "\*,            |\*|,    +     ;/;/+` '|_"..*_           |\\|    +     \ !./    \\ '*_.           |^\\   +     ~|~:      /\\;/'           / "/   +     / ,_    ,||/;"/|==_;_=+~~  |~=*   +     |,^;'  //;"*+/|; \,____/"~/' |'   +      /`\,'/\_|/^`  `"^^^^*^^*//_,|    +       ".^\/~               _/* ,^     +        \;`^;,:,          ,;/_**'      +          "^_"~*~-:~~+~;/*"_/;"        +             "^~;=__/__++*"^           +                                       \ 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 new file mode 100644 index 00000000000..74d83c8e702 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_36.txt @@ -0,0 +1,17 @@ +                                       +              __+=++++=+,_             +          _=""\+/;/+\+;++"**+_         +        ,\'\,+*-*"`` `"*~*+|,*|,       +      _|"*+____            '*~\"|      +     ,/_;\'|\`\,.             ^\.*     +     / ,/`  *_ "|/,            "\^*    +    | ;!`     !\ "\\            |^|,   +    ||\~      _\ _//!           \| |   +    |'"|     // ,*"',++_+++++_  |\~|   +     _*|\  ,|__/~/ !`~_______|| \/'`   +     ' *|\ +_+/^     "**^^^^^" |,"/    +      ',"\;.                 ,/|"/     +        \/||+~,           ,++"/,`      +          *,_"**=^;~_+~;"-",;+'        +            `*+/~_,,_,,++**"           +                                       \ 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 new file mode 100644 index 00000000000..06dbce99c07 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_4.txt @@ -0,0 +1,17 @@ +                                       +             _.=;+==+=+,_              +         _-"+*|/!|\=/*;|"/*,_          +       ,*=|||+*"`   `^~\^*|/\\_        +      //|,|".+,          "|**\*\       +     /|/_|=|\;^|,          \"\|*\      +    /||^|  '_||/*\          ' \=|,     +    "\|;`   '**\+"|,         , ";|     +     |.\     ,|/*^||           |||     +    _|!|_   ;|/"^//+~+++____,, ||*     +    |\/!| ,///,_|`=\|,._,:/^;|//"|     +     `|;'\,\\,\/   "*******'^-|||`     +      ,|\"|_`             ,^;/**'      +       \\,:^!,_        _.^,*;|/        +         ~||=_**\;;=;,+=*+\|*`         +            *\\~:~_____;-*`            +                                       \ 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 new file mode 100644 index 00000000000..6b1ce124479 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_5.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+===+,_              +         _+"+/*/||+~=+;_|"+,           +        *_/\+|*"`   "^=|*\!,*,         +      ,|/|/|.=,          -^||||_       +     ,\/*/^_+|_\,         ` \|\|_      +     ~|||/ \_|\;/|_          +/||      +    |/|!+   `\_|\"|,        ''~+|,     +    |/|_"    ,_=|/_|          +|_|     +    |,|^*   /_|",|*=_~+~___+,_"|.|     +     _\|\,,/+*" |"!//\=^,=.,/|/*|`     +     \,||,~,\\/+`  "**""""",~;|\/      +      \\+/\\ `            /_/*,^       +       ^/*\|=+_        _=+,*';*        +         ^|*|,*+\;~=_,+=*^~;*          +            ^-*_*"___-_,+*`            +                                       \ 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 new file mode 100644 index 00000000000..7724f483dc6 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_6.txt @@ -0,0 +1,17 @@ +                                       +             _.=;;+==++__              +          ,^;/*|=*+|++;_,*+_           +        ,,,|||*"   `"~.=*+*/\_         +       /,|*|*_=,        \+||||,        +      '=|||*\\+*\         .\\|\,       +     ;-/|/`^+;|\_|,        .=\/|       +     _ +~|   !^\|/^\        ^+|_|      +     ~ |~|   _|=~/||        ~+\||      +     _.|-|  /'||*;/+_~+~__++_.\/|      +     ||\,/_^_;+ |/=*,||,;==\|,|\!      +      / | /~||;/"  "*"""'*"`\\/|       +       , ; /,`           ,:_//|`       +        .`\_**,       _+^_/*,+         +         `"~*,"+!;~__,+**!:;'          +            ^-;*+,___`_,+*             +                                       \ 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 new file mode 100644 index 00000000000..0d0f43072c6 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_7.txt @@ -0,0 +1,17 @@ +                                       +             _.~;*+==+,_               +          ,*`+\|+*+==\;!`*,            +         * ,|||*`  `^~`!*/_*,          +        \\|||~;+       ^~\*|/,         +       `'|*||^|/\,       . *||\        +      | ;||" `'\;|\       ,-|||        +       `\"|  ^_|\|\|       **^||       +      _"/~|   =+/|*|`      ' |||       +       /"~* ,"_/|\/~~;;_~~=;*\||       +      |,'||=:~|/'|.\||\\,=,;\|\|       +       \ =^/*|*_/  ******""_/||        +       '_ /_/_`         ,"-/;/'        +        ';,+\\\_      ,^~,*/*'         +          \_'\|*\;~___+*|*+/           +            "~*"~:~__,_;**             +                                       \ 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 new file mode 100644 index 00000000000..2e8019c0612 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_8.txt @@ -0,0 +1,17 @@ +                                       +             __+_;++=+,_               +           ,"*/|||*==!~ "+             +         _|=,|//*!,"~/~*\+^,           +        _*\*/|,+|     ' =^||\          +        ' /|/,\|/\      .'\||,         +       |',|\^^_\|_*      \+|||         +       |*||| '_;\|`|      ^|/|,        +       \,||/  |+\/|,*.    .`/||        +       \ \||_^ !/*=|~/+,+,,\~||        +       !  \*/=_|",|,||;|=__||='        +        //\\|\|||/'^*^*"\*"/\|         +        ',"|\|``        .,///'         +         '\_*\\\_    _/\_|+/*          +           .:'|,*!;;+*;/=,|`           +             ~~_**\|_,+/=`             +                  ``                   \ 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 new file mode 100644 index 00000000000..128e9150078 --- /dev/null +++ b/codex-rs/tui2/frames/default/frame_9.txt @@ -0,0 +1,17 @@ +                                       +              .=^*/++=,                +            /*_/||*"=!_-\_             +           / ,||/*^^=/!\_~,            +          " ||/;=_ _^,|\^|+,           +         /*";\*"|*,  +:+||           +         | |||"^|\;*   '|*|||          +         ` ~*\ **|\\," / `||          +        |  ~/_ ~||_/= | !||          +        !  ",|" /|"|~~|+|~,||`         +         |_|||_,|^|_||||__|||          +         " `^/\\|/"****||"/\|          +          \~||\,`     ,/ ^/|`          +           \+\|\\    /;_;|/*           +            ^-"\_|*/+=;;*|`            +             '-_ *\\|;+/"              +                                       \ 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 new file mode 100644 index 00000000000..36964a48647 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_1.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●●○○●●○ + ○○●◉●○●◉●○○··○○ ○ ●○ + ●·●·●●● ○· · ●· ·○···● + ◉●○○●●●●○ ◉●·◉·● + ○○◉◉●○·○·○○ ◉·○○● + ·● ●· ·●◉◉··● ●◉○··● + ●○ ◉● ●○·●◉ ·○ ·◉· + ··○·· ●◉◉·◉·● ·· + ·○·●· ◉··○○· ◉·●●●●●●○●● ○ · + ○·◉● ○◉●· ◉● · ·○○○◉◉●●●·◉●◉●· + ○○○ ○ ○○●◉◉· ·○●●●● ○● ◉●◉○◉ + ●○● ● · ●●◉●○· + ○○●·○●○ ○○○●·○● + ○●●○○ ●●◉◉○◉◉◉○●●○●·● + ·● ●···○○○●○◉●● + \ 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 new file mode 100644 index 00000000000..3c687d7f64f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_10.txt @@ -0,0 +1,17 @@ + + ○●●●●○●●○ + ●●·●●●○·◉○●● + ○○●··◉··◉·●○●● + ·○○◉○·◉○◉○●●●○○● + ◉ ◉·· ·○ ●●◉○◉·◉ + ·· ●·●·◉◉○ ·○ · + ○ ·● ●····● ·◉● + ··○●◉●··◉○·◉○·· + ·○ ○○·◉··●○●◉·· + · ●·○●·○◉○●○○○·●· + ● ····· ○● ○·· + ○·●●○●● ○· ●◉◉ + ·● ····●●◉●◉·◉· + ◉·●●◉·●◉○ ◉◉● + ●● ○●○○●◉● + \ 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 new file mode 100644 index 00000000000..c2548db4b3c --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_11.txt @@ -0,0 +1,17 @@ + + ●●●●●●●○ + ◉ ◉···○○● + ◉ ○○··◉○●◉○● + ·○○··○◉●○○◉◉ + ○ ◉◉○·●○●◉○··● + ·●···●○◉◉·○◉·· + ●● ●······○·○○ + · ··◉··○◉ · · + ◉ ●·◉··●◉●·○· + ●● ○·◉○● ◉·○·○ + ●·◉ ····○◉·○○· + ○○◉◉·○●◉●●◉·· + ○ ●·○○○○·◉ + ○● ●··●○◉●· + ○○ ●●●○●· + \ 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 new file mode 100644 index 00000000000..30b03392bf4 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_12.txt @@ -0,0 +1,17 @@ + + ●●●●●◉ + ●○·○◉·●○ + ·◉○●● + ◉●···○○ · + · ●····◉◉· + ·●◉◉●○ ○· + · ·◉○··○ + · ◉●○·· + ●● ·◉··●·· + · ◉·●◉●○ + ·◉ · ○●●◉ + ● ●· ○●· + ◉ ●●·●●○· + ●○◉ ●·○·· + ○ ●·○◉◉ + \ 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 new file mode 100644 index 00000000000..cb95f3763d3 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_13.txt @@ -0,0 +1,17 @@ + + ◉●●●● + ·○○·· + ··○· + ·●●·○ + ● ·· + ◉◉·· + ● ·· + ● · + ◉● ○·◉ + · ●○· + ●·◉◉ ·· + ·○○○· + ·●●·○ + ●○○·○ + ● ◉ ○ + \ 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 new file mode 100644 index 00000000000..3a8ed60b8ff --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_14.txt @@ -0,0 +1,17 @@ + + ●●●●◉ + ●◉○○◉○ + ·○··◉ · + ··◉· ○·● + ···○ · + ·●·· ●· + ···· ◉ ● + ··○·· ○ + ···· + ○◉◉ ◉◉ + ○· ●◉●· + ·●··○○○· + ◉ ·○○○· + ·○◉●○○◉ + ○● ●○◉ + \ 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 new file mode 100644 index 00000000000..c57b4af0ee5 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_15.txt @@ -0,0 +1,17 @@ + + ●●●●●·○ + ·●○··○◉●● + ◉◉·●○○· ●· + ·◉· ◉◉●◉○○· + ··○ ○○··◉ ○· + ○··◉··◉◉· + ·· ······●· + ··◉··●··· ●◉· + ··●●●●···○◉◉· + ·○·◉◉·○·●◉ ◉· + ·◉····○· · ◉ + ·●●◉○··○◉ ○ + ●·○● ◉◉◉· ◉· + ·○··○○◉●○◉ + ○○●○○○○◉ + ·· \ 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 new file mode 100644 index 00000000000..18ae0e09ee3 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_16.txt @@ -0,0 +1,17 @@ + + ○○● ●●●·○ + ◉○··●··○·○○ + ·◉◉ ○◉○○·· ●○ + ◉◉◉ ◉●○○·○··●●· + ··◉○◉●●○··●◉··○ + ···●·● ·○○ · ·◉· + ○○· ◉·●····◉● ● · + ·◉◉ ····◉··· ◉ ·· + ○○···●·◉○·· ●● ◉ + ● ○○●○○○●·◉○· ●○· + ···● ◉●○·◉ ◉◉··· + ··● ·· ·◉◉● ◉ + ○·● · ◉●◉○●· + ○·· ○●·◉◉●○· + ●◉◉○○◉○○◉● + \ 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 new file mode 100644 index 00000000000..a470b4ba8df --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_17.txt @@ -0,0 +1,17 @@ + + ●○●●●◉◉·●○ + ○◉●●···●○●·● ○● + ●●○◉○○○·●● ···○ ○ + ◉·◉○◉· ○· ●·◉○···○○ + ·◉○ · · ◉○○○◉◉ ● + ·○●●◉· ●◉◉· ◉●○◉·◉◉ + ● ◉◉· ●◉◉·○ ● ··◉○· + ◉ ○· ◉◉··◉·●··○◉● + ●◉●◉○○○●·○○·○○◉○··· · + · ○◉○○·◉●○◉··○ ○●●●○ + ○○○◉ ●●●●○○·●◉◉○ ○● + ○··○● ·◉··· ◉ + ○○○ ●○○ ●●●◉○○◉ + ● ·●●○○●·○◉●◉·● + ·●◉◉○●●·●○●● + · \ 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 new file mode 100644 index 00000000000..c0354b39331 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_18.txt @@ -0,0 +1,17 @@ + + ○○○●○○●◉·●○ + ○● ○◉●●●·○○● ●◉◉○ + ○◉◉·● ●○ ● ◉○ ●◉◉○ ● + ●··●◉●○ ● ●○·○◉○·○◉○○○● + ○○◉◉ ◉· ○ ●○○◉·◉·○○◉○○○ + ◉○· ●●·◉○○○◉●●●·●◉◉○◉·◉· + ··○◉◉ ●◉●◉· ○◉ ●·○·○● + ○◉·· ○○ ··◉○ ·◉· + ○ ●○○○◉○◉●··●·●◉○● ·●·· + ○○○·●○● ··◉○○○○···○◉··○ ● + ·◉·○· ●●●●●· ○○○◉·◉●◉·◉· + ○○ ●○● ··● ◉●○● + ○○●●○○◉ ○●○○◉○●· + ●·◉●●●●○○○○●●○◉○●● + ··○◉○○●◉● ◉● + · \ 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 new file mode 100644 index 00000000000..c9ded568388 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_19.txt @@ -0,0 +1,17 @@ + + ○○○○●·●○○·◉○ + ○●···○◉○○○○●○●·●●● + ●●◉◉◉ ·●●· ·●◉●·●◉○ + ◉◉·●●○· ○○○●●○·●● + ··◉·◉· ●·○ ◉ ·○ ·○● + ·○◉●● ○·●·◉ ◉◉ ●◉●· + ○○··● ◉●○◉·●●· ◉◉◉●· + ···· ·◉ ○· ● · ··· + ·●·○●○◉◉○○○····●●◉●·◉ ○ ·· + ○◉◉●·○●◉○●●○●·● ○○ ◉○○○●◉◉·○ + ●○ ◉●· ●● ○○○◉·◉◉ ◉◉● + ○○○●○○ ·◉◉○◉◉● + ● ○○··●● ●◉○◉◉·◉· + ● ◉·●◉·●○○○◉◉● ◉●·● + ·◉○◉◉●○●◉○●◉○ + \ 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 new file mode 100644 index 00000000000..6e7a27fb294 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_2.txt @@ -0,0 +1,17 @@ + + ○◉○◉○●○○○●●○ + ○●●◉●◉●···○··○○●·●●○ + ●·●·○●●·●·· · ●○●◉··◉·● + ○·◉○◉●●●●○ ◉●○·◉·● + ●○ ◉●○·○○○○○ ○●·○·● + ●◉● · ·●○●◉○● ○○·●· + ●·●●· ○◉·◉·○·● ◉ · · + · ○· ●·●··◉● ·●◉◉· + ● ○· ◉·○○○· ◉·●●●●●●●●●·○··● + ○○●· ◉··● ◉● ·○○○○○○◉◉○○·●◉ · + ○○○ ● ●○◉◉·● · ●●●●●●● ·●●·· + ●○○◉◉● · ●●◉◉●● + ◉○○○·●● ●○○● ○· + ··○◉○○ ●●◉◉○◉◉◉○●·○··● + ·●●●●··○○○○·◉◉● + \ 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 new file mode 100644 index 00000000000..d9809e733cc --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_20.txt @@ -0,0 +1,17 @@ + + ○○●○··○●○●○○ + ●◉○ ◉●○●○○○··○○●○●● + ●● ○●● ·● · ○ ○●●·●○ + ○· ○●·● ○●●·○◉●○○ + ○· ●·● ○◉ ○··○●○··○ + ◉·●·● ●●●◉· ◉ ●○··○ + ◉·◉●· ●● ◉●●○ ●○·◉ + ·○○◉· ◉◉· ● ·· · + ·◉··◉●○◉○○○○○○○●●◉○○○○● ◉ ·○ + ●·○·○ ◉··○○○○·○ ●○○ ·○○ ●◉ ·· + ○●○○● ● ○●○··●· ◉○ + ● ○●◉● ○◉●◉·◉ + ○○○●●·○ ●◉●·◉●● + ○●◉◉ ·●○◉◉●◉ · ·●◉●● + ·●●·○ ○○○○○·● + \ 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 new file mode 100644 index 00000000000..0821f12d752 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_21.txt @@ -0,0 +1,17 @@ + + ○○●○●○○●○●●○○ + ●●●··○○●·○·●◉○·◉●●○ + ◉●○○●●○ · ·○·●●○·●● + ●●·◉ ● ○●◉●●◉●● + ◉●◉●· ●·◉○○ ○○○○● + ◉ ◉· ●●●○◉◉ ○○○● + ·●·· ○ ◉·●◉● ○◉○ + ·○ ○◉ ·● · · + · · ●●◉◉◉◉◉●●● ○·● ·● ◉◉ · + · ·○ ·○ ○●· ○ ·●·● ◉ ◉◉ + ◉ ◉ ·· ●· ●●○●◉ ◉· ● + ●● ◉◉○ ○● ◉ + ●○ ○◉○ ○◉◉●● ●· + ●○○ ● ●●◉··◉◉○● ◉· + ●●●◉●○○○○●○◉●●· + \ 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 new file mode 100644 index 00000000000..d6733498019 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_22.txt @@ -0,0 +1,17 @@ + + ○●●○●○○●○●●○ + ○●◉●·◉○◉·◉○··◉·●◉●●○ + ●◉●◉·○●●· · ●○●●●○○●● + ◉●·●● ●●···○●○ + ○··◉● ●○◉·○·◉◉◉●· + ○·●◉· ●·◉·◉○◉· ○·○○· + ·●●○◉ ·●·○●◉● ··◉· + ··○·· ·○··○○ ·○· · + ··● ● ●·○○◉◉◉○●●○○●·○ ○● ○·◉ + ○·○ ●○○○● ◉● ·◉◉· ○●◉○ ·●● ◉○○ + ○○○○· ●● · ·◉●●○◉◉◉ ●· + ○●○○○● ○●● ·●● + ● ●◉ ●●○ ○◉●● ●○◉ + ●◉●●● ●·◉○●◉◉◉○● ●●● + · ·○◉○○○◉○○◉○●· + \ 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 new file mode 100644 index 00000000000..180ab167842 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_23.txt @@ -0,0 +1,17 @@ + + ○●●○○●●●●○●○○ + ○○·●·○○●○○○●·○·●·●○ + ● ●·●●◉●· ·◉●·○◉··● + ◉◉·●◉◉· ○◉◉○··◉○ + ◉◉··◉● ●◉●●●●◉●○·○ + ●○· ◉· ○●●●●●◉●●··○·● + ··· · ◉◉◉◉ ○·· ·●● + ·◉· · · ·●○○● · ●◉· + ○ ○ ·●·◉○○○○○◉○·○●·○ ○○ · ··· + ·○○○· ○ ◉·○ ○·○○●· ○○· + ◉●· · · ●·◉◉○·○◉○◉· + ●··○·◉ ●◉·○○◉· + ○ ·●●◉○○ ○◉ ◉●◉●◉ + ○○○●◉●●○·○○○●● ◉○●●· + · ●○○·○○●○○●●· + \ 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 new file mode 100644 index 00000000000..3244b1c6f92 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_24.txt @@ -0,0 +1,17 @@ + + ○·○◉●●●○○●○ + ● ○◉ ·○○·○○●●○●● + ● ●◉○◉● · ◉·◉●·●●● ○ + ●●◉◉●◉·○●○··◉· ○●○·○○● + ● ◉◉○◉● · ◉○○···○·●● + ·●○●○· ●●●◉ ◉·◉ ◉ ○ + · ·· ●◉○●◉ ◉○○·○○·○ + · ○·· ·●○◉○○··○◉◉··· + ·◉ ·◉◉◉◉●●···●●○●●○· · ·· + ○ ◉ ●·◉●●◉◉●●·◉○○●●· ·●· + ○ ●○·○● · ○●◉●○·○◉●· + ○ ○●●○ · ◉◉◉ + ○◉●○··◉ ○○ ○◉◉○● + ◉◉●●○○ ··◉○○◉·○○◉· + ●◉○●●◉○○○○○●● + \ 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 new file mode 100644 index 00000000000..c04ef18b74f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_25.txt @@ -0,0 +1,17 @@ + + ○●○●◉●●●●○ + ● ●●●·○◉○·●··● + ◉·◉◉●● ◉·●○○○···○ + ·●●· ◉ ○· ○·●●○◉ + ●○○○●◉●○◉◉○· ·◉◉○◉● + ○●···○●●◉○·◉ ◉·●·◉◉·● + ·◉·●· ·○○◉○○◉·○◉○ ·○· + ···◉·◉ ·○○○◉·○○·○··○ + ○●○· ·○○○○○○●··○○·●●·○ + ◉◉ ·◉·●·○··◉●○·○●○◉○· + ○·●◉○·· ●●◉·○·◉·· + ·○ ·◉●◉○◉●●◉○◉●◉◉·◉ + ○·○·○○○●◉◉●○◉○··◉ + ○◉◉○○○●○·●● ●·● + ·● ●○●●○●·● + \ 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 new file mode 100644 index 00000000000..1ecc43beef2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_26.txt @@ -0,0 +1,17 @@ + + ○◉●●● ●● + ◉●·◉◉○·●◉·● + ◉● ·◉○·○ ○● + ◉● ●···○··○●◉○ + ·○●○·◉· ●○◉◉·○◉ + ○ ·◉◉◉ ●○○ ○○◉ + · ●●○··◉··◉◉ ● + ◉◉ ○···○○○··◉·· + · ◉○●◉●·●○○◉○·· + ◉○·◉●·○○●●○○●· + ·· ◉·● ○●◉·●○·· + ○○··◉○ ◉◉ ··· + ● ●·○ ○·◉·◉● + · ●○○ ●○●●◉ + ○◉◉○●·○○● + \ 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 new file mode 100644 index 00000000000..83e62da52e2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_27.txt @@ -0,0 +1,17 @@ + + ◉●●●●● + ·◉◉◉·◉○ + ◉●· ●·◉●· + ···○··◉·· + · ··○◉●○ + ·○◉○ ····· + ○ ··○●·· + · ····○ ·· + ·◉ ◉ ·●···· + ●○ ◉ ○·○●·· + ·◉ ○○●●●◉● + ··●●··○◉· + ·○○●○●◉·○ + ○○○·○○◉◉ + ●○ ○○◉ + \ 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 new file mode 100644 index 00000000000..6d460c936de --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_28.txt @@ -0,0 +1,17 @@ + + ◉●●◉ + ·●○·· + ○ ◉·· + ·◉·●· + · ·· + · ◉ · + ○·· ○· + ·◉ · + ◉○ ○· + ··◉◉·· + · ○· + ·○●○· + ·○●·· + ●○○◉· + · ◉●· + \ 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 new file mode 100644 index 00000000000..d0d6b3c286d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_29.txt @@ -0,0 +1,17 @@ + + ●●●●●● + ●·●◉ ●○ + ○●·· ● · + ····○○●◉ + ○ · · · + ·●··· ○○ + ·○○··· ○ + ··○·· + ···●· · + ○·○·· · + ●··◉·○ ○ + ·◉··· ○· + ······○· + ·○·●◉ ·● + ··● ◉● + \ 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 new file mode 100644 index 00000000000..062da3ed89f --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_3.txt @@ -0,0 +1,17 @@ + + ○◉○◉●●○○○○●○ + ○●●◉○··●· ○○·◉○○·●●○ + ●◉◉●◉·● · · · ●●●◉○·● + ◉●◉○·◉●●○ ·○● ··○ + ●◉●·◉○◉··●○○ ○○○··○ + ◉·○○◉ ◉●·○◉○○ · ○○· + ○● · ●●○·○ ·● ◉●◉·● + ●◉·●· ●◉· ◉○· ·○·○· + ◉ ●· ◉·●●··●◉·○●●○○○●●○·○ ◉· + ●○◉○● ◉◉●◉◉◉· ·●·○○● ○◉●◉○··○·· + ●○· · ··●● ●●● ●●●◉◉◉●◉ + ●●· ○● · ◉○●○◉◉ + ○○◉◉●○● ●●◉◉ ●·● + ○○●●○●●○○◉○◉●◉○●◉·○● + ·●●●○◉·○○○○·◉◉○ + \ 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 new file mode 100644 index 00000000000..4bf02ade3d8 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_30.txt @@ -0,0 +1,17 @@ + + ○◉ ●●●● + ○◉◉··○●●○○ + ●○·●○○·●●○ + ··○·◉·○○·○ ○● + ○●···○·●·● · + ·●·○● ●····◉· + ○◉···○○ ○◉○· ○· + · ●●○·●· ·◉ · + ·○ ○·○·○◉·● · + ··○·○○·· ●● · + ○··◉○○●···●·· + ·●◉◉●◉●○·· · + ○·○··◉●··●·◉ + ●○●●○○◉·●◉● + ○◉◉·○○○◉● + ···· \ 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 new file mode 100644 index 00000000000..99385ee51fa --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_31.txt @@ -0,0 +1,17 @@ + + ○◉◉●●●●○● + ○●●◉●●○ ●○●●● + ●·○◉◉●○○·◉ ·○●○ + ●·◉●○○●◉○●○○·○··○ + ○····●◉●○○● ○·◉○○ + ◉·○··○·● ○◉●●◉◉·● · + ◉·· ·○●·○◉○◉◉●·●·◉· + ○○● ·· ·○ ·○ ··○◉· + ·○●○·●○···◉●···○ · + ···◉◉··○○○○○·○·· ● + ○●○◉●·● ◉●●●·◉·●·○ + ···○○○● ○○● · + ◉·●○○ ● ◉ ·●·◉· + ◉·○·◉○··●· ●·· + ● ○·○●●◉◉◉● + ···· \ 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 new file mode 100644 index 00000000000..771e9c9106b --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_32.txt @@ -0,0 +1,17 @@ + + ●●●●●◉◉·●○ + ◉●·●●·○··●●··● + ◉○●○○●●○◉◉·●·●○●●● + ●◉◉·○●·◉◉ ◉ ●○○·●● ● + ◉◉·●·●○●●·◉ ●·●·○◉○●○● + ○◉○○●○●··◉··●○ ·●◉ ● + ●· ◉●·●○··●●·●○·◉○◉◉·●○ + ···●○ ○·· ◉●·◉◉○ ○· · + ○··◉○ ●○· ○○·●●●···○· ○· + ● ○··●○●○○○○○○··●·◉·◉○ + ●○●··◉ ·● ●●●○◉◉◉◉◉◉ + ○●·○○○ ●○·◉●· + ●●●···●○ ○◉○·◉ ● + ○◉○ ·●·◉○●·○● ○◉ + ·◉○○○●●◉●○ + ··· \ 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 new file mode 100644 index 00000000000..4d36c1eb6f2 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_33.txt @@ -0,0 +1,17 @@ + + ○●●○●○●○·◉○ + ○●●··○●○·◉·◉·●·●◉ + ○◉○·●●●●○ · ●◉··● ● + ●○◉·◉○·●○ ●··○○● + ○◉·◉···◉○○○ ·●○○○ + ◉◉● ○○○○·○● ◉○◉○○ + ··● ○●●·○·○ ·●·· + ●·●● ●●●○◉○● ○ ·● + ··· · ●· ◉●●●●◉○○●··●◉○·○· + ○◉·● ◉◉●◉···○○○○○··●○○○··◉ + ○○○● ·◉·◉ ◉ ●●● ··●◉○· + ○●○●● ● ◉◉◉◉○◉ + ●○ ●●●◉● ○●·●◉●○● + ○· ●○●·●◉●○ ◉●○●·● + ○○●●○○○○●●◉◉ + ···· \ 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 new file mode 100644 index 00000000000..4cbd99c1435 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_34.txt @@ -0,0 +1,17 @@ + + ○●○●●●●○·●○ + ○●●●◉○◉· ·●●○●●◉●○○ + ○◉◉●·● ◉·●· ·○○○● ●●● + ●◉○·●○◉●○ ●·●●○○ + ◉◉·○◉◉· · ● ○○●○○ + ●◉○··◉ ○○ ·○●○ ○●○·○ + ·●○·○ ●○○●·○○ ·◉·○ + ○·○ ◉ ·●○·◉ · ◉ ○○ + ●●◉◉ ◉○◉◉ ○○○●●●●●●·○○ ·○· + ··○●◉●●○ ◉◉●···○·◉○○○●◉○····○ + ◉○○● ·○·●○ ●●····○ ●◉●○·● + ◉●○◉○○○ ◉◉◉·◉● + ○●··●○◉● ○●○ ○◉●· + ◉○◉· ·●●◉●●○●○●·●●◉● + ·○◉○○○○○○◉○●○○ + ··· \ 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 new file mode 100644 index 00000000000..5ccdf711b5b --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_35.txt @@ -0,0 +1,17 @@ + + ○●●◉○●●●○●●○ + ●●○●◉·●●● ·●·◉ ·●○○ + ○◉◉·◉●●·○● · · ●○●◉ ·●● + ●◉◉●◉●◉●○ ●●○○·○ + ◉◉··◉◉○ ○●● ·○●·● + ◉◉◉◉●· ●·○ ◉◉●○ ·○○· + ○ ◉◉ ○○ ●●○◉ ·○○○ + ···◉ ◉○○◉◉● ◉ ◉ + ◉ ●○ ●··◉◉ ◉·○○○◉○○●·· ··○● + ·●○◉● ◉◉◉ ●●◉·◉ ○●○○○○◉ ·◉● ·● + ◉·○●●◉○○·◉○· · ○○○○●○○●◉◉○●· + ◉○○◉· ○◉● ●○ + ○◉·○◉●◉● ●◉◉○●●● + ○○ ·●·◉◉··●·◉◉● ○◉◉ + ○·◉○○○◉○○●●● ○ + \ 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 new file mode 100644 index 00000000000..6a26abaea68 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_36.txt @@ -0,0 +1,17 @@ + + ○○●○●●●●○●●○ + ○○ ○●◉◉◉●○●◉●● ●●●○ + ●○●○●●●◉● ·· · ●·●●·●●·● + ○· ●●○○○○ ●●·○ · + ●◉○◉○●·○·○●◉ ○○◉● + ◉ ●◉· ●○ ·◉● ○○● + · ◉ · ○ ○○ ·○·● + ··○· ○○ ○◉◉ ○· · + ·● · ◉◉ ●● ●●●●○●●●●●○ ·○·· + ○●·○ ●·○○◉·◉ ··○○○○○○○·· ○◉●· + ● ●·○ ●○●◉○ ●●○○○○○ ·● ◉ + ●● ○◉◉ ●◉· ◉ + ○◉··●·● ●●● ◉●· + ●●○ ●●○○◉·○●·◉ ◉ ●◉●● + ·●●◉·○●●○●●●●●● + \ 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 new file mode 100644 index 00000000000..b4496013b5e --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_4.txt @@ -0,0 +1,17 @@ + + ○◉○◉●○○●○●●○ + ○◉ ●●·◉ ·○○◉●◉· ◉●●○ + ●●○···●● · ·○·○○●·◉○○○ + ◉◉·●· ◉●● ·●●○●○ + ◉·◉○·○·○◉○·● ○ ○·●○ + ◉··○· ●○··◉●○ ● ○○·● + ○·◉· ●●●○● ·● ● ◉· + ·◉○ ●·◉●○·· ··· + ○· ·○ ◉·◉ ○◉◉●·●●●○○○○●● ··● + ·○◉ · ●◉◉◉●○··○○·●◉○●◉◉○◉·◉◉ · + ··◉●○●○○●○◉ ●●●●●●●●○◉···· + ●·○ ·○· ●○◉◉●●● + ○○●◉○ ●○ ○◉○●●◉·◉ + ···○○●●○◉◉○◉●●○●●○·●· + ●○○·◉·○○○○○◉◉●· + \ 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 new file mode 100644 index 00000000000..0905c495b26 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_5.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○○●●○ + ○● ●◉●◉··●·○●◉○· ●● + ●○◉○●·● · ○○·●○ ●●● + ●·◉·◉·◉○● ◉○····○ + ●○◉●◉○○●·○○● · ○·○·○ + ····◉ ○○·○◉◉·○ ●◉·· + ·◉· ● ·○○·○ ·● ●●·●·● + ·◉·○ ●○○·◉○· ●·○· + ·●·○● ◉○· ●·●○○·●·○○○●●○ ·◉· + ○○·○●●◉●● · ◉◉○○○●○◉●◉·◉●·· + ○●··●·●○○◉●· ●● ●·◉·○◉ + ○○●◉○○ · ◉○◉●●○ + ○◉●○·○●○ ○○●●●●◉● + ○·●·●●●○◉·○○●●○●○·◉● + ○◉●○● ○○○◉○●●●· + \ 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 new file mode 100644 index 00000000000..3f96b667617 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_6.txt @@ -0,0 +1,17 @@ + + ○◉○◉◉●○○●●○○ + ●○◉◉●·○●●·●●◉○●●●○ + ●●●···● · ·◉○●●●◉○○ + ◉●·●·●○○● ○●····● + ●○···●○○●●○ ◉○○·○● + ◉◉◉·◉·○●◉·○○·● ◉○○◉· + ○ ●·· ○○·◉○○ ○●·○· + · ··· ○·○·◉·· ·●○·· + ○◉·◉· ◉●··●◉◉●○·●·○○●●○◉○◉· + ··○●◉○○○◉● ·◉○●●··●◉○○○·●·○ + ◉ · ◉···◉◉ ● ●● ·○○◉· + ● ◉ ◉●· ●◉○◉◉·· + ◉·○○●●● ○●○○◉●●● + · ·●● ● ◉·○○●●●● ◉◉● + ○◉◉●●●○○○·○●●● + \ 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 new file mode 100644 index 00000000000..aa52e1b869d --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_7.txt @@ -0,0 +1,17 @@ + + ○◉·◉●●○○●●○ + ●●·●○·●●●○○○◉ ·●● + ● ●···●· ·○·· ●◉○●● + ○○····◉● ○·○●·◉● + ·●·●··○·◉○● ◉ ●··○ + · ◉·· ·●○◉·○ ●◉··· + ·○ · ○○·○·○· ●●○·· + ○ ◉·· ○●◉·●·· ● ··· + ◉ ·● ● ○◉·○◉··◉◉○··○◉●○·· + ·●●··○◉··◉●·◉○··○○●○●◉○·○· + ○ ○○◉●·●○◉ ●●●●●● ○◉·· + ●○ ◉○◉○· ● ◉◉◉◉● + ●◉●●○○○○ ●○·●●◉●● + ○○●○·●○◉·○○○●●·●●◉ + ·● ·◉·○○●○◉●● + \ 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 new file mode 100644 index 00000000000..5791ce70e48 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_8.txt @@ -0,0 +1,17 @@ + + ○○●○◉●●○●●○ + ● ●◉···●○○ · ● + ○·○●·◉◉● ● ·◉·●○●○● + ○●○●◉·●●· ● ○○··○ + ● ◉·◉●○·◉○ ◉●○··● + ·●●·○○○○○·○● ○●··· + ·●··· ●○◉○··· ·○·◉·● + ○●··◉ ·●○◉·●●◉ ◉·◉·· + ○ ○··○○ ◉●○··◉●●●●●○··· + ○●◉○○· ●·●··◉·○○○··○● + ◉◉○○·○···◉●○●○● ○● ◉○· + ●● ·○··· ◉●◉◉◉● + ●○○●○○○○ ○◉○○·●◉● + ◉◉●·●● ◉◉●●◉◉○●·· + ··○●●○·○●●◉○· + ·· \ 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 new file mode 100644 index 00000000000..35588ee1ee7 --- /dev/null +++ b/codex-rs/tui2/frames/dots/frame_9.txt @@ -0,0 +1,17 @@ + + ◉○○●◉●●○● + ◉●○◉··● ○ ○◉○○ + ◉ ●··◉●○○○◉ ○○·● + ··◉◉○○ ○○●·○○·●● + ◉● ◉○● ·●● ●◉●●◉●·· + · ··· ○·○◉○○ ·●·●··· + · ·●○·●●·○○● ◉●◉ ··· + · ·◉○●○···○◉○○○· ·· + ●· ◉· ····●··●··· + ·○···○●·○·○····○○··· + ·○◉○○·◉ ●●●●·· ◉○· + ○···○●· ●◉ ○◉·· + ○●○·○○ ◉◉○◉·◉● + ○◉ ○○·●◉●○◉◉●·· + ●◉○ ●○○·◉●◉ + \ 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 new file mode 100644 index 00000000000..45adbbac247 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_1.txt @@ -0,0 +1,17 @@ + + -.-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 new file mode 100644 index 00000000000..0e9a76d4d8f --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_10.txt @@ -0,0 +1,17 @@ + + -#****##- + *█-#*#*.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 new file mode 100644 index 00000000000..b7e743b218b --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_11.txt @@ -0,0 +1,17 @@ + + #****##- + 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 new file mode 100644 index 00000000000..0c6c85043f9 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_12.txt @@ -0,0 +1,17 @@ + + #***#. + #*--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 new file mode 100644 index 00000000000..097cd508d7e --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_13.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..8eca9095040 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_14.txt @@ -0,0 +1,17 @@ + + #**** + #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 new file mode 100644 index 00000000000..cbf646ab35c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_15.txt @@ -0,0 +1,17 @@ + + ##***.- + -#*..-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 new file mode 100644 index 00000000000..82698755af1 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_16.txt @@ -0,0 +1,17 @@ + + -*#█**#.- + 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 new file mode 100644 index 00000000000..57d02179e70 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_17.txt @@ -0,0 +1,17 @@ + + #*###**.#- + -***...***.#█-# + #**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 new file mode 100644 index 00000000000..ef524a0ed91 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_18.txt @@ -0,0 +1,17 @@ + + -**#**#*.#- + -#█-*###.--#█#**- + -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 new file mode 100644 index 00000000000..80a9abf0128 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_19.txt @@ -0,0 +1,17 @@ + + --**#.#**..- + -#...*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 new file mode 100644 index 00000000000..843df90f283 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_2.txt @@ -0,0 +1,17 @@ + + -.-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 new file mode 100644 index 00000000000..b588df38946 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_20.txt @@ -0,0 +1,17 @@ + + --#*..*#*#-- + #**█*#-#***..--**## + ##█-#*█.*█ -█-█*#*.#- + -.█-*.* -##.*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 new file mode 100644 index 00000000000..0d1fc7ec26e --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_21.txt @@ -0,0 +1,17 @@ + + --#*#**#*##-- + ##*----#.*.#*---*#- + **--#*-█- --.*#--*# + #*.*██ -#**#**# + 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 new file mode 100644 index 00000000000..8fbfdb57138 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_22.txt @@ -0,0 +1,17 @@ + + -##*#**#*##- + -#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 new file mode 100644 index 00000000000..ef2f8adb709 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_23.txt @@ -0,0 +1,17 @@ + + -##-*#*##*#-- + -*.#.*-#-**#.-.*.#- + #█#.█#**- █.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 new file mode 100644 index 00000000000..09a7fd520cb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_24.txt @@ -0,0 +1,17 @@ + + -.-*##***#- + #█-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 new file mode 100644 index 00000000000..af8bb947f60 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_25.txt @@ -0,0 +1,17 @@ + + -#***####- + # *#*--**.*..# + 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 new file mode 100644 index 00000000000..7ff85c300af --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_26.txt @@ -0,0 +1,17 @@ + + -****█## + 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 new file mode 100644 index 00000000000..06e988b0761 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_27.txt @@ -0,0 +1,17 @@ + + ****## + .*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 new file mode 100644 index 00000000000..0e258181458 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_28.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..7f2ddab00a4 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_29.txt @@ -0,0 +1,17 @@ + + #****# + #.*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 new file mode 100644 index 00000000000..8cce426bb4a --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_3.txt @@ -0,0 +1,17 @@ + + -.**##****#- + -##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 new file mode 100644 index 00000000000..24a2165e45b --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_30.txt @@ -0,0 +1,17 @@ + + -*█**## + -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 new file mode 100644 index 00000000000..65f139ab962 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_31.txt @@ -0,0 +1,17 @@ + + -.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 new file mode 100644 index 00000000000..6cbec21aeca --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_32.txt @@ -0,0 +1,17 @@ + + #####**.#- + **.**.*..**..# + 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 new file mode 100644 index 00000000000..a661feb2aff --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_33.txt @@ -0,0 +1,17 @@ + + -##*#*#*..- + -#*..***.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 new file mode 100644 index 00000000000..3427025326c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_34.txt @@ -0,0 +1,17 @@ + + -#*####*.#- + -#*#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 new file mode 100644 index 00000000000..e0919ec5d0e --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_35.txt @@ -0,0 +1,17 @@ + + -##**###*##- + #***..**#█.*.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 new file mode 100644 index 00000000000..0355f68b47c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_36.txt @@ -0,0 +1,17 @@ + + --#*####*##- + -*██*#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 new file mode 100644 index 00000000000..2b4b7c670bb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_4.txt @@ -0,0 +1,17 @@ + + -.**#**#*##- + --█#*.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 new file mode 100644 index 00000000000..c71575690bb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_5.txt @@ -0,0 +1,17 @@ + + -.***#***##- + -#█#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 new file mode 100644 index 00000000000..799e3a1cf5a --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_6.txt @@ -0,0 +1,17 @@ + + -.***#**##-- + #-*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 new file mode 100644 index 00000000000..4a3f9f202fb --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_7.txt @@ -0,0 +1,17 @@ + + -..**#**##- + #*-#*.#*#**** -*# + * #...*- --.- *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 new file mode 100644 index 00000000000..4bc5a6f1186 --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_8.txt @@ -0,0 +1,17 @@ + + --#-*##*##- + #█*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 new file mode 100644 index 00000000000..db3507db59c --- /dev/null +++ b/codex-rs/tui2/frames/hash/frame_9.txt @@ -0,0 +1,17 @@ + + .*-*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 new file mode 100644 index 00000000000..ab8be3eb1e1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▇▄▄▇▆▂ + ▂▄▆▅▇▃▇▅▇▃▄▁▁▄▂ ▂█▇▂ + ▆▁▇▁▇▇▇█▃▂ ▂█▇▂█▁▄▁▁▁▇ + ▄▇▂▃▇█▆▆▂ ▅▇▁▄▁▆ + ▃▃▄▅█▃▁▃▂▃▃ █▅▁▃▃▆ + ▁▇ ▇▂ ▁▇▅▄▁▁▆ █▅▃▁▁▆ + ▇▃█▆▇ █▃▁▇▅█▁▂ ▁▅▁ + ▁▁▂▁▂ ▆▅▅▁▄▁▇ █▂▁ + ▁▄▁█▂ ▄▁▁▃▃▁█▅▁▇▇▇▇▇▇▂▇▆ ▄█ ▁ + ▂▁▄▇ ▂▄▇▂ ▅▇ ▁█▁▂▂▂▅▅▆▆▆▁▅▆▅▆▁ + ▃▃▂█▃ ▃▃▆▅▅▂ ▂▃▇██▇ ▃▇█▅▆▄▂▅ + ▇▃▆ █▆ ▂ ▆█▅▇▂▁ + ▃▃▆▂▃▇▂ ▂▄▂▇▁▂▇█ + ▃▇▆▃▂ ▇▇▅▄▄▄▄▅▄▇▇▂▆▁▇ + ▂▇█▇▁▁▁▂▂▂▆▂▄▇▇█ + \ 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 new file mode 100644 index 00000000000..5e565ce40b9 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▂▇▇▇▇▃▇▇▂ + ▇█▂▇▇▇▃▁▅▂▇▆ + ▃▂▆▁▁▅▁▁▆▁▇▃▆▆ + ▁▂▂▅▃▁▄▂▅▃▆██▃▃▆ + ▅ ▄▁▁█▁▃ █▆▅▂▅▁▅ + ▁▂ █▁▇▁▅▅▃ ▂▂█▁ + ▃ ▁▇█▇▁▁▁▁▇ ▁▅▆ + █ ▁▁▃▇▅▇▁▁▆▂▂▅▃▁▁ + █ ▁▃ ▃▃▁▄▁▁▇▃▇▄▁▁ + ▁ ▆▁▃▆▁▂▅▂▇▂▂▂▁▇▂ + ▆ █▁▁▁▁▁██ ▃▆█▃▁▂ + ▃▂█▆▃▆▇█ ▂▁██▆▅▅ + ▁█ ▁▁▁▁▇▆▅▆▅▁▅▂ + ▄▂▇▇▅▁▇▄▂ ▅▅█ + ▇▆ ▂▇▃▂▆▄▇ + \ 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 new file mode 100644 index 00000000000..5305252a8d1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▇▇▂ + ▅█ ▄▁▁▁▃▃▆ + ▅█▂▂▁▁▄▃▇▅▃▆ + ▁▂▂▁▁▄▄▆▂▄▅▅ + ▂█▅▄▃▁▇▃█▄▂▁▁▆ + ▁▇▂▁▁▇▂▄▄▁▂▅▁▁ + ▇▇ ▆▁▁▁▁▁▁▂▁▄▃ + ▁ ▁▁▅▁▁▃▅ ▁█▁ + ▅ █▁▅▁▁▇▅▇▁▂▁ + ▇▇ ▂▁▅▄▆█▅▁▂▁▃ + █▂▆ ▁▁▁▁▄▅▁▃▃▁ + ▃▂▆▅▁▂▇▅▇▇▄▁▂ + ▂ ▇▁▃▃▃▂▁▄█ + ▃▇ ▇▁▁▆▂▅▇▂ + ▃▂ █▇▇▂▇▂ + \ 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 new file mode 100644 index 00000000000..cebfe226e1e --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▅ + ▆▄▂▂▅▁▆▃ + █ ▁▅▃▇▆ + ▅▇▁▁▁▄▄█▁ + ▁ █▁▁▁▁▅▅▁ + ▁█▅▅▇▃█▂▁█ + ▁ ▁▅▃▁▁▃ + ▁ ▅▇▃▁▁█ + ▇▆ ▁▅▁▁▇▁▁ + ▁ ▅▁▇▄▇▂ + ▁▅ ▁█▄▇▇▅ + ▆ ▇▁ ▂▆▁ + ▅ ▇▇▁▆▇▃▁ + █▃▅█▆▁▃▁▂ + ▃ █▁▃▅▅ + \ 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 new file mode 100644 index 00000000000..566cc4ffa30 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▅▇▇▇▆ + ▁▂▂▁▁ + ▁▂▂▁█ + ▁▇▇▁▂ + ▇ ▁▁ + █▅▆▁▁ + ▆ ▁▁ + ▇ █▁ + ▅▇ ▂▁▅ + ▁█ ▇▄▁ + █▂▅▅ ▁▂ + ▁▂▂▂▁ + ▁▇▆▁▂ + ▇▂▂▁▄ + ▆ ▅█▄ + \ 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 new file mode 100644 index 00000000000..380790e11c9 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▄ + ▆▅▂▂▅▃ + ▁▂▁▁▅ ▁ + ▁▁▅▁█▃▁▆ + ▁▁▁▃ ▁ + ▁▇▁▁ █▁ + ▁▁▁▁ ▅ ▇ + ▁▁▃▁▂ ▃ + ▁▁▁▁ █ + █▃▅▅ ▄▅ + ▃▁██▆▅▆▁ + ▁▇▁▁▂▂▂▁ + ▅ █▁▄▄▄▂ + ▁▃▅▇▂▂▅ + ▂▇ █▄▅ + \ 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 new file mode 100644 index 00000000000..47d169e98bc --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▁▂ + ▂▆▄▁▁▃▅▇▆ + ▅▅▁▇▄▃▁█▇▁ + ▁▅▂ ▅▅▆▅▂▂▁ + ▁▁▄█▄▃▁▁▅█▃▁ + ▃▁▁▆▁▁▅▅▁ █ + ▁▁ ▁▁▁▁▁▁▆▁ + ▁▁▅▁▁▇▁▁▁ █▆▁ + ▁▁▇▆▆▇▁▁▁▂▅▅▁ + ▁▂▁▄▅▁▃▁▇▅ ▅▁ + ▁▅▁▁▁▁▂▁ ▂ ▄ + ▁▇▇▅▃▁▁▃▆ ▄ + █▁▃▆ ▅▅▅▂ ▄▂ + ▁▃▁▁▃▃▅▇▃▅ + ▃▃▇▃▂▂▂▅ + ▂▂ \ 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 new file mode 100644 index 00000000000..3b1fb1fc5d4 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▂▄▇█▇▇▇▁▂ + ▅▃▁▁▇▁▁▃▁▄▃ + ▁▅▅█▃▅▄▃▁▁ █▃ + ▅▅▅ ▄▇▂▃▁▃▁▁▇▇▁ + ▁▁▄▂▅▇█▄▁▁▇▅▁▂▃ + ▁▁▁▇▁█ ▁▃▄ ▁ ▁▅▁ + ▃▃▁ ▅▂▆▁▁▁▁▅▇█▆ ▁ + ▁▅▄ ▁▂▁▁▅▁▁▁█▄ ▂▁ + ▃▃▁▁▁▇▁▅▃▁▁█▇▇ ▅ + ▇█▂▂▆▄▄▃▇▁▅▂▁ ▆▂▁ + ▁▁▁▇██▅▇▃▁▄█▄▅▁▁▂ + ▁▁▇ ▁▂ ▂▅▅▆ ▅ + ▃▁▇ ▁ ▅▆▅▂▆▁ + ▃▁▁█▂▇▁▅▅▇▂▁ + █▅▅▂▄▅▂▂▄▇ + \ 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 new file mode 100644 index 00000000000..93817e2eadd --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▆▄▇▇▇▄▄▁▆▂ + ▂▄▇▇▁▁▁▇▄▇▁▆█▃▆ + ▆▇▃▅▂▄▄▂▇▆█▁▁▁▂█▃ + ▅▁▅▂▅▁█▂▂ █▁▄▃▁▁▁▄▃ + ▁▅▂ ▁ ▁█▅▃▄▃▅▅ ▆ + ▂▄▇▆▅▂ ▆▅▅▁ ▅▆▄▅▁▅▅ + ▇ ▄▅▁ ▆▅▅▁▂ ▇█▁▁▅▄▁ + ▆█▄▁ ▅▅▁▁▅▁▆▁▁▄▄▇ + ▆▅▇▅▃▄▄▇▁▃▂▁▃▃▅▃▁▁▁ ▁ + ▁█▂▄▂▂▁▅▇▃▅▁▁▃ ▃▇█▇▃ + ▃▃▃▅███▇▇▇▇▃▂▁▇▅▅▃ ▃█ + ▃▁▁▂▇ ▂▅▁▁▂ ▄ + ▂▃▃ ▇▃▂ ▆█▆▅▃▂▅ + ▆ ▁▇▇▄▃▇▂▂▄▇▅▁█ + ▂▇▄▅▂▆▇▁▇▂▇▇ + ▂ \ 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 new file mode 100644 index 00000000000..03d2c5e94b8 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▂▄▄▇▄▄▇▄▁▆▂ + ▂▇█▂▄▆▇▇▁▂▂▆█▇▄▄▂ + ▂▅▅▁▇█▇▄█▆█▅▂█▇▅▅▃ ▇ + ▆▁▁▇▅▆▃█▇ █▃▂▂▅▄▁▃▄▃▃▃▆ + ▂▃▅▅ ▅▁ ▂█▇▂▃▅▁▅▁▂▃▄▃▃▂ + ▅▃▁ ▆▆▁▅▂▂▂▅▆▇▆▁█▆▅▃▅▂▅▁ + ▁▁▃▅▅ ▇▅▇▅▁█▂▅█▇▁▄▁▄▆ + █▃▆▁▁ ▃▃█▁▁▄▃ ▁▄▁█ + ▃██ ▆▃▄▄▄▄▄▇▁▁▆▁▇▅▃▆ ▁▇▁▂ + ▂▃▃▁▇▃▇ ▁▁▅▂▂▃▂▁▁▁▂▄▁▂▃ ▆ + ▁▅▁▃▂██▇▇▇▇▇▂ ▃▂▂▅▁▅▇▅▂▆▂ + ▃▃ ▆▃▆ ▂▂▇█▅▇▂█ + ▃▃▇▇▃▃▅ ▂▇▃▂▅▃▆▂ + █▁▅▇▇▇▇▄▄▄▃█▇▂▅▂▆▇ + █▁▁▂▅▂▂▆▅▇█▄▇█ + ▂ \ 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 new file mode 100644 index 00000000000..f8267761700 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▂▂▄▄▇▁▇▄▄▁▅▂ + ▂▇▁▁▁▃▅▄▄▄▂▇▂▆▁▇▆▆ + ▆▇▄▄▅█▁▇▇▂ ▂▇▅▆▂▇▄▃ + ▅▄▁▇▆▃▂ ▂▄▄▇▆▃▁▇▆ + ▁▁▅▂▅▂ ▆▁▃█▅█▁▃ ▁▃▆ + ▁▃▅▇█ ▂▁▇▁▅ ▅▅ █▅▇▁ + ▃▄▁▁█ ▅▇▃▅▁▇▆▂ ▅▅▅▇▁ + ▁▁▁▁ ▁▄█▃▁█▆ ▁ ▁▁▁ + ▁▇▁▃▆▄▄▄▄▄▄▁▁▁▁▇▇▄▇▁▄ ▃ █▁▁ + ▃▄▅▆▁▄▇▅▃▇▇▃▆▂▆ ▃▂ ▅▃▂▃▆▅▅▁▂ + █▃█▅▇▁█████▇▇█ ▃▃▂▅▁▅▅ ▅▅█ + ▃▃▃▇▂▃ ▂▅▅▂▅▅█ + █ ▂▃▁▁▇▆ ▆▄▄▅▅▁▄▂ + ██▅▁▇▅▁▇▂▂▄▄▅▇█ ▄▇▁█ + █▁▅▂▅▆█▂▆▅▃▇▆▃ + \ 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 new file mode 100644 index 00000000000..d4efa4def0e --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▂▅▂▅▄▇▄▄▄▇▆▂ + ▂▇▆▅▇▄▇▁▁▁▄▁▁▄▂█▁▇▇▂ + ▆▁▇▁▃▇▇▁▇▂▂ ▂█▇▄▇▅▁▁▄▁▆ + ▂▁▅▂▅▇█▆▆▂ ▆▆▃▁▅▁▆ + ▆▃█▅▇▃▁▃▂▃▃▂ ▃▆▁▃▁▆ + █▅▇█▂ ▁▇▃▇▅▃▇ ▃▃▁▇▁ + ▆▁█▇▁ ▃▅▁▄▁▂▁▆ ▅█▁█▁ + ▁█ ▃▂ ▆▁▇▁▁▄▇ ▁█▅▅▁ + ▇██▄▁ ▄▁▃▃▂▁█▅▁▇▇▇▇▇▇▇▇▆▁▂▁▁▇ + ▂▄█▁ ▅▁▁█ ▅▇ ▁▄▃▂▂▂▂▅▅▂▂▁▇▅ ▁ + ▃▃▃ ▇ ▇▃▅▅▁█ ▂█▇▇▇▇▇▇▇█▁▆▇▁▁ + █▃▂▅▅▆ ▂ ▆▇▄▅▆▇ + ▅▃▂▂▁▇▆ ▆▄▂▇ ▂▁█ + ▂▁▃▄▂▂█▇▇▅▄▄▄▄▅▄▇▂▂▁▁▇ + ▂▇▇▇▇▁▁▂▂▂▂▁▄▅▇█ + \ 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 new file mode 100644 index 00000000000..30c29f51c9b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▁▁▄▇▄▆▂▂ + ▆▄▄█▄▆▂▆▄▄▄▁▁▂▃▇▃▇▆ + ▆▆█▂▇▇█▁▇█ ▂█▃█▃▇▇▁▇▂ + ▂▁█▂▇▁▇ ▂▆▇▁▃▅▇▃▃ + ▂▁ ▆▁▇ ▂▅ ▂▁▁▃▇▃▁▁▃ + ▅▂▆▁█ ▇▇█▄▁█▅ █▃▁▁▃ + ▄▁▄▇▁ ▆▇ ▅▇▇▃ ▆▃▁▅ + ▁▂▃▅▂ ██▄▅▁█▆ ▁▁█▁ + ▁▅▁▁▄▆▄▄▄▄▄▄▄▄▂▆█▄▃▃▃▃▇ ▅ ▁▃ + █▁▃▁▂█ ▅▂▂▂▂▂▂▁▃ ▇▃▃█▁▄▃ ▆▅ ▁▁ + ▃▆▃▃████████▇█ ▃▆▄▁▁▆▁ ▅▃ + ▇ ▃▇▅▆ ▂▅▇▅▁▅ + ▃▂▃▇▆▁▂ ▆▄▇▂▄▇█ + ▃▆▆▅█▁▇▃▄▄▆▅█▁█▂▆▅▇█ + ▂▇▇▁▂ ▄▂▂▂▂▁▇██ + \ 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 new file mode 100644 index 00000000000..b6a6c2c109c --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▂▂▆▄▇▄▄▇▄▇▆▂▂ + ▆▇▇▂▂▂▂▇▁▄▁▇▄▂▂▆▇▇▂ + ▄▇▃▂▇▇▃█▂ ▂▃▁▇▇▂▂▇▆ + ▆▇▁▄██ ▂▆▄▇▆▄▇▆ + ▅▆▅▇▂ ▆▁▅▂▃ ▃▃▂▃▆ + ▅█▅▁ ▆▇▇▂▅▅ ▃▃▃▆ + ▁█▁▂ ▂ ▅▂▆▅█ ▃▄▃█ + ▂▃ ▃▄ █▁▆ ▁ ▁ + ▁ ▁ ▆▇▄▄▄▄▄▇▇▇ ▃▁▆█▁▆ ▄▅ ▁ + ▁ ▁▃ ▁▂ ▂▆▁ ▃█▂▇▁▆ ▅█▅▅ + ▄ █▅ ▂▂███ █▂ █▇▂▆▄ ▅▂ ▆ + █▇ ▅▄▂ ▂▇█ ▅ + ▇▂ ▃▆▂ ▂▅▄▇█ ▆▂ + ▇▄▂ ▇█▇▇▅▁▁▄▅▄▇██ ▆▂ + ▇▇▇▄▇▂▂▂▂▆▂▄▇▇▂ + \ 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 new file mode 100644 index 00000000000..38195cd38b3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▄▇▄▇▆▂ + ▂▇▅▇▁▅▂▅▁▅▃▁▁▄▁▇▅▇▇▂ + ▆▄▇▄▁▃▇▇▂ ▂█▇▃▇▇▇▂▂▇▆ + ▄▇▁▇▇█ ▆▆▁▁▁▂▇▃ + ▃▁▁▅█ ▆▂▅▁▄▁▅▅▄█▁ + ▃▁▇▅▂ ▆▁▅▁▄▃▅▂ ▃▁▃▄▁ + ▁▇▇▃▅ ▁▇▁▃▇▅█ ▁▁▅▁ + ▁▁▄▁▂ ▁▃▁▁▃▃ ▁▃▁█▁ + ▁▁▆█▆ ▇▁▄▄▄▄▄▄▇▇▂▃▇▁▂ ▃▆ ▂▁▄█ + ▃▁▄ ▇▂▃▃▆ ▅▆█▁▅▅▁ ▃▇▄▂█▁▆▆ ▅▃▄ + ▃▃▃▂▁ █████▇▇█▂ ▂▅▇▇▂▅▅▅█▆▂ + ▃▇▃▂▃▆ ▂▇▆█▁▆█ + ██▇▄ ▇▇▂ ▂▅▇▆█▇▂▅ + █▅▇▇▆█▇▁▄▄▇▄▄▅▄▇███▆▇ + █▁█▁▂▄▂▂▂▆▄▂▅▄▇▂ + \ 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 new file mode 100644 index 00000000000..a81cac3ef20 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▂▆▇▂▄▇▇▇▇▄▇▂▂ + ▂▄▁▇▁▃▂▆▂▄▄▆▁▂▁▇▁▇▂ + ▆█▇▁█▆▄▇▂ █▁▅▇▁▃▄▁▁▆ + ▅▄▁▇▄▅▂ ▂▄▄▃▁▁▄▃ + ▅▅▁▂▅█ ▆▅▆▆█▇▅▇▃▁▃ + ▆▃▁█▅▂ ▂▇▇▇██▅▇█▁▁▃▁▆ + ▂▁▁█▁ ▅▅▅▅ ▂▁▂ █ ▁█▇ + ▁▅▁ ▁ ▁█▁▆▂▃▆ ▁ ▇▅▁ + ▃█▃ ▁▇▁▄▄▄▄▄▄▄▄▁▂▇▁▂█▃▂ ▁ ▁▁▁ + ▁▃▃▂▁█▄█ ▅▂▃ ▃▁▂▃▆▁ ▃▂▁ + ▅█▁ ▁ ▂███████ █▂▅▄▄▁▂▅▃▅▂ + ▆▁▁▂▁▅ ▆▅▁▃▃▅▂ + ▃ ▁▆█▅▂▂ ▂▅█▄▇▅▇▅ + ▃▄▃▇▄▇▇▃▁▄▄▂▇▇█▆▂▇▇▂ + █▁ ▇▄▃▁▂▂▇▂▂▇▇▂ + \ 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 new file mode 100644 index 00000000000..791f93b5914 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▂▁▂▄▇▇▇▄▄▆▂ + ▆█▂▅█▁▂▂▁▄▄▇▆▂█▇ + ▆██▄▃▅▇█▁ ▅▂▅▇▁▇▇▆ ▃ + ▆█▅▅▆▅▁▄▆▃▂▁▅▂ ▂▇▃▁▃▂█ + ▆ ▅▅▃▅▆ ▂██▅▃▂▁▂▂▃▁▆█ + ▁▆▃▇▃▂ ▇▆▆▅ ▅▁▄ ▅█▃ + ▁ ▁▁ ▆▅▂▆▅█▅▃▃▁▃▃▁▃ + ▁ ▃▁▁ ▁▆▄▅▃▂▁▁▂▅▅▂▁▁ + ▁▅ ▁▄▄▄▅▇▇▁▁▁▇▇▂▇▆▂▁ ▁ ▁▁ + ▂ ▅ ▇▁▅▆▆▄▄▆▆▁▅▃▃▇▇▁ ▁▇▁ + ▃ █▃▁▃▇██████▂ ▃▆▅▇▃▁▄▅▆▂ + ▃ ▃▆▇▃ ▁ █▅▄▅ + ▃▅▇▃▂▁▅ ▂▄█▂▅▅▂▇ + ▅▆▇▆▃▃█▁▁▅▄▄▄▁▃▂▅▂ + ▇▅▂▇▇▄▃▂▂▄▄▇▇ + \ 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 new file mode 100644 index 00000000000..565fdb82ead --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▂▇▄▇▄▇▇▇▇▂ + ▆ ▇▆▇▂▂▄▃▁▇▁▁▆ + ▅▁▅▅▆▆█▅▁▇▃▄▃▁▁▁▂ + ▁▇▇▁ ▅ ▄▁ █▃▁▆▇▃▅ + ▆▂▂▃▆▅█▃▄▅▂▁█ ▁▅▅▃▅▇ + ▃▆▁▁▁▂▇▇▅▃▁▅ ▅▁▆▁▅▅▁▆ + ▁▅▁▇▁ ▁▂▂▅▃▂▅▁▃▅▂█▁▄▁ + ▁▁▁▅▁▅█ ▁▂▄▃▅▁▃▂▁▄▁▁▃ + ▃▇▃▁█▁▂▂▄▄▄▄▇▁▁▃▃▁▇▇▁▃ + ▅▆█▁▅▁▆▁▂▁▁▄▇▂▁▃▇▄▅▃▁ + ▃▁▇▅▃▁▂██████▇▅▁▃▁▅▁▂ + ▁▂█▁▄▇▄▃▆▆▇▅▂▅▆▅▅▁▅ + ▃▂▃▁▂▃▂▇▄▅▆▃▅▂▁▁▅ + ▃▅▄▃▃▂▇▄▁▇▇ ▇▁▇ + ▁▆█▇▃▇▆▂▇▁▇ + \ 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 new file mode 100644 index 00000000000..e37d671dc4b --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▂▄▇▇▇█▇▆ + ▅▇▁▄▅▃▁▇▄▁▆ + ▅▇██▁▄▃▁▃██▃▆ + ▄▇█▆▁▁▁▂▁▁▂▇▅▃ + ▁▂▆▃▁▅▁█▇▃▅▄▁▃▄ + ▃ █▁▅▅▅█▇▂▃█▃▃▅ + ▁ ▇█▄▁▁▅▁▁▄▄█▇ + ▅▆ ▃▁▁▁▃▃▄▁▁▅▁▁ + ▁ ▅▃▇▄▇▁▇▄▄▅▃▁▁ + ▅▂▁▅▆▁▂▂▆▇▃▃▇▁ + ▁▂█▅▁▇█▃▆▅▂▇▃▁▁ + ▃▂▁▁▅▃ ▅▅█▁▁▂ + ▇ ▇▁▃ ▃▂▅▁▅▆ + ▁█▇▃▃█▇▂▇▇▄ + ▃▅▅▃▇▁▂▃▇ + \ 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 new file mode 100644 index 00000000000..d3dbefa9754 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▄▇▇▇▇▆ + ▁▄▅▅▁▅▃ + ▄█▂ ▇▁▅█▁ + ▁▁▁▃▁▁▄▁▁ + ▁ ▂▁▂▆█▄ + ▁▂▅▂█▂▁▁▁▂ + ▄ █▁▁▂▇▁▁ + ▁ ▂▁▁▁▄█▁▁ + ▁▅ ▅ ▁▇▁▁▁▁ + █▄ ▅█▂▁▂▇▁▁ + ▁▆ ▂▃▇▇▇▅█ + ▁▁▇▇▁▁▂▅▁ + ▁▄▄▇▄▆▅▁▃ + ▃▂▂▁▃▄▅▅ + █▃█ ▃▃▅ + \ 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 new file mode 100644 index 00000000000..0ae0f54e0b0 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▅▇▇▄ + ▁▇▂▁▂ + ▄ ▄▁▂ + ▁▆▂▇▁ + ▁ ▁▁ + ▁ ▆█▁ + ▃▁▁ ▂▁ + ▁▆██▁ + ▅▃ ▂▁ + ▁▁▅▅▁▁ + ▁ ▂▁ + ▁▄▇▄▁ + ▁▄▇▁▁ + ▇▂▂▅▁ + ▁ ▅▇▁ + \ 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 new file mode 100644 index 00000000000..d333f278dce --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▇▇▇▇▇▆ + ▆▁▇▅ ▇▃ + ▃▆▁▁ ██▁ + ▁▁▁▁▃▃▆▅ + ▃ ▁█▁ ▁ + ▁▆▁▁▁ ▂▂ + ▁▃▃▁▁▁ ▃ + ▁▁▄▁▁ + ▁▁▁▇▁ ▂ + ▂▁▄▁▁ ▂ + ▇▁▁▅▁▃ ▄ + ▁▅▁▁▁ ▂▁ + ▁▁▁▁▂▁▂▁ + ▁▄▁▇▅█▁█ + ▁▁▇ ▅▆ + \ 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 new file mode 100644 index 00000000000..5d0b07202ae --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▇▄▄▄▄▆▂ + ▂▇▆▅▃▁▁▇▁█▄▄▁▄▂▃▁▇▇▂ + ▆▄▅▇▄▁▇██▂ ▂█▁ ▇▇▇▅▃▁▆ + ▅▇▅▃▁▄▆▇▂ ▂▄▇ ▁▁▃ + █▅▇▁▅▃▅▁▁▇▃▂ ▃▃▃▁▁▂ + ▅▁▃▃▅ ▅▆▁▃▄▃▃ ▁ ▃▃▁ + ▄▇ ▁ █▇▃▁▂█▁▆ ▅▇▅▁▆ + ▆▆▁▆▁ ▆▆▁█▅▃▁ ▁▂▁▄▁ + ▆██▁ ▄▁▇▇▁▁▇▆▁▄▇▇▂▂▂▇▇▂▁▃█▄▁ + ▆▃▅▃▆ ▅▅▇▅▄▄▁ ▁▇▁▂▂▆ ▄▅▆▄▃▂▁▃▁▂ + █▃▁ ▁ █▁▁▇▆█ █▇▇▇███▇▇▆▅▅▅▆▅ + █▇▁ ▃▇ ▂ ▅▃▇▃▅▅ + ▄▃▅▅▇▃▆ ▆▇▄▅█▆▁█ + ▄▃▇▆▂▇▇▃▄▄▄▄▆▅▄▇▄▂▂▇█ + ▂▇▇▇▂▅▁▂▂▂▂▁▄▅▃█ + \ 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 new file mode 100644 index 00000000000..7ceb36d37ac --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▂▄█▇▇▇▆ + ▂▅▄▁▁▃▇█▄▃ + ██▃▁▆▃▃▁▇▆▃ + ▁▁▃▁▅▁▂▃▁▃ ▃▆ + ▄▇▁▁▂▃▁▆▁▆ ▁ + ▁▇▁▃▇ ▇▁▁▁▁▅▁ + ▃▅▁▁▁▃▂ ▃▅▃▁ ▄▂ + █▁ █▇▄▁▆▁█▁▆ ▂ + █▁▂█▃▁▄▁▃▅▁▆ ▂ + █▁▁▃▁▂▂▁▁ ▇▆ ▂ + ▃▁▁▄▃▂▇▁▁▁▇▁▁ + ▁▇▅▅▆▅▇▃▁▁█ ▁ + ▃▁▃▁▁▅▇▁▁▆▁▅ + ▆▃▆▇▄▃▅▁▆▅█ + ▃▆▅▁▃▂▂▄█ + ▂▂▂▂ \ 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 new file mode 100644 index 00000000000..419be30ed96 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▂▅▅▇▇▇▇▄▆ + ▂▇▇▄▇▆▂█▇▃█▇▆ + ▆▁▃▅▅▆▄▂▂▅█▁▂▇▃ + ▆▁▅▇▂▂▇▄▂▆▃▃▁▃▁▁▃ + ▃▁▁▁▁▇▆▇▃▂▆█▃▁▅▂▂ + ▄▁▃▂▁▂▁██▃▄▇▆▅▅▁▆█▁ + ▅▁▁ ▁▃▇▁▃▅▄▅▄▇▁▇▁▅▁ + ▃▃▆ ▁▁█▁▃ ▁▃██▁▁▃▅▁ + ▁▃▆▂▁█▃▁▁▁▅▇▁▁▁▂ ▁ + █▁▁▁▅▅▁▁▂▂▂▃▂▁▃▁▁ ▇ + ▃▇▂▅▇▁▆ ▅▇▇▇▂▅▁█▁▂ + ▁▁▁▃▃▃▆ ▂▄▇ ▁ + ▄▁▇▄▂ ▆ ▅ ▁▇▂▅▂ + ▅▁▃▁▅▂▁▂▆▁█▆▁▂ + ██▄▁▂▇▇▅▅▄▇ + ▂▂▂▂ \ 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 new file mode 100644 index 00000000000..1234a419b0c --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▆▇▇▇▇▄▄▁▆▂ + ▄▇▁▇▇▁▃▁▁▇▇▁▁▆ + ▅▃▇▄▃▆▇▃▅▆▂▇▁▇▃█▇▆ + █▅▅▁▂▆▂▄▆ ▆ ▆▂▃▁▇▆ ▆ + ▅▅▁▆▁▇▃█▇▁▄ █▂▆▁▃▅▃▇▃▆ + ▃▅▃▃▆▃▇▁▂▅▁▁▇▂ ▁▇▅ ▇ + ▇▁█▄▆▂█▃▁▁▆▆▁▆▄▂▅▂▅▅▁█▂ + ▁▁▁▆▂ █▂▁▁█▅█▁▄▆▂█▃▁██▂ + ▃▁▁▆▂ █▂▁█▂▂▁▇▇▇▁▁▁▄▁█▄▂ + ███▂▂▁▇▃█▃▂▂▂▂▂▁▁▇▁▄▁▆▂ + ▆▃▇▁▁▅ ▁▇██▇▇▇▃▄▅▅▅▅▅ + ▃▆▁▃▃▃ ▆▃▁▅█▁ + █▇▇▁▁▁▇▂ ▂▅▂▁▅ ▆ + ▃▆▃█▁▇▁▄▄▇▂▂▇█▂▅ + ▁▄▄▂▂▆▇▅▇▂██ + ▂▂▂ \ 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 new file mode 100644 index 00000000000..780eb104ef3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▇▄▇▄▁▅▂ + ▂▇▇▁▁▃▇▄▁▅▁▆▁▇▁▇▅ + ▂▅▃▁▇▇▇▆▄█▂ █▇▅▁▁▇█▆ + ▆▃▅▁▅▂▁▆▂ ▇▁▁▃▂▇ + ▂▅▁▅▂▁▁▅▃▃▃ ▁▇▃▃▃ + ▅▅▆ ▃▂▃▃▂▃▆ ▅▃▅▂▃ + ▁▁▇ ▃▆▇▁▂▁▃ ▁▇▁▁ + ▇▁▆▆ ▆██▃▅▄▆ ▃█▁▇ + ▁▁▁ ▁ ▆▂ ▅▇▆▆▆▄▄▄▇▁▁▇▅▂▁▃▁ + ▃▅▁██▅▅▆▅▂▁▁▂▂▂▂▂▁▁▇▃▃▃▁▁▅ + ▃▄▃▆ ▁▅▁▅ ▅ ████▇▇▇ ▁▁▆▅▃▂ + ▃▇▃▇▆█▆ ▅▅▄▅▄▅ + ▇▃█▇▆▇▄▆ ▂▇▁▆▅▇▃█ + ▃▁█▇▃▇▁▆▄▇▂█▄▆▃▇▁▇ + █▄▄▇▆▂▂▂▃▇▇▄▆█ + ▂▂▂▂ \ 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 new file mode 100644 index 00000000000..4bf69e69eb4 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▂▆▄▇▇▇▇▄▁▆▂ + ▂▇▇▇▅▃▄▁█▁▇▇▄▇▇▄▆▄▂ + ▂▅▅▆▁▇█▅▁▇▂ ▂▃▃▃▆█▇▇▆ + ▆▅▂▁▇▂▅▆▂ ▇▁▆▇▃▃ + ▅▅▁▃▆▅▁ ▁█ ▆ ▃▃▇▃▃ + ▆▅▃▁▂▅ ▃▃█▁▃▇▃ ▃▆▃▁▃ + ▁▇▃▁▂ █▃▂▇▁▂▃ ▁▄▁▂ + ▃▁▄█▅ ▁▇▂▁▅█▁ ▅ ▂▃ + ▆█▅▅█ ▅▃▅▅█▂▄▄▇▇▇▇▇▇▁▂▂█ ▁▃▂ + ▁▁▃▆▅▆▆▃█▅▅█▁▁▁▂▁▅▂▂▂▆▅▂▁▁▂▁▃ + ▅▂▃▆ ▁▃▁▇▃ █▇▇▂▂▂▂▃ ▆▅▆▃▁█ + ▅▆▃▄▄▃▂ ▅▄▅▁▅█ + ▃▇▂▁▇▃▄▆ ▂▇▄█▂▅▆▂ + ▆▃▅▁█▁▇▆▄▆▇▄▇▄▇▂▇▇▅█ + █▁▃▄▄▂▂▂▂▂▄▄▇▄▃ + ▂▂▂ \ 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 new file mode 100644 index 00000000000..86dde2ad341 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▂▆▇▄▄▇▇▇▄▇▆▂ + ▆▇▄▇▅▁▇▇▇█▁▇▁▅█▁▇▄▂ + ▂▅▅▁▄▇▇▁▃▇█▂ ▂█▇▄▇▅█▁▇▆ + ▆▅▅▇▅▆▄▆▂ ▇▇▂▃▁▂ + ▅▅▁▁▅▅▃ █▃▇▆ ▁▃▇▁▆ + ▄▅▄▅▇▂ █▁▂█▅▅▇▂ ▁▃▃▁ + ▃ ▅▅ ▃▃ █▇▂▅ ▁▃▃▃ + ▁▁▁▅ ▅▃▃▄▅█ ▅ █▅ + ▅ ▆▂ ▆▁▁▅▄█▅▁▄▄▂▄▂▄▇▁▁ ▁▁▄▇ + ▁▆▃▄█ ▅▅▄█▇▇▅▁▄ ▃▆▂▂▂▂▅█▁▅█ ▁█ + ▅▂▃▆█▅▃▂▁▅▃▂ ▂█▃▃▃▃▇▃▃▇▅▅▂▆▁ + █▅▃▃▅▁ ▂▅▇ ▆▃ + ▃▄▂▃▄▆▅▆ ▆▄▅▂▇▇█ + █▃▂█▁▇▁▆▅▁▁▇▁▄▅▇█▂▅▄█ + █▃▁▄▄▂▂▅▂▂▇▇▇█▃ + \ 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 new file mode 100644 index 00000000000..bccadcf7b78 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▂▂▇▄▇▇▇▇▄▇▆▂ + ▂▄██▃▇▅▄▅▇▃▇▄▇▇█▇▇▇▂ + ▆▃█▃▆▇▇▆▇█▂▂ ▂█▇▁▇▇▁▆▇▁▆ + ▂▁█▇▇▂▂▂▂ █▇▁▃█▁ + ▆▅▂▄▃█▁▃▂▃▆▅ ▃▃▅▇ + ▅ ▆▅▂ ▇▂ █▁▅▆ █▃▃▇ + ▁ ▄ ▂ ▃ █▃▃ ▁▃▁▆ + ▁▁▃▁ ▂▃ ▂▅▅ ▃▁ ▁ + ▁██▁ ▅▅ ▆▇██▆▇▇▂▇▇▇▇▇▂ ▁▃▁▁ + ▂▇▁▃ ▆▁▂▂▅▁▅ ▂▁▂▂▂▂▂▂▂▁▁ ▃▅█▂ + █ ▇▁▃ ▇▂▇▅▃ █▇▇▃▃▃▃▃█ ▁▆█▅ + █▆█▃▄▅ ▆▅▁█▅ + ▃▅▁▁▇▁▆ ▆▇▇█▅▆▂ + ▇▆▂█▇▇▄▃▄▁▂▇▁▄█▆█▆▄▇█ + ▂▇▇▅▁▂▆▆▂▆▆▇▇▇▇█ + \ 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 new file mode 100644 index 00000000000..5867215a96d --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▇▄▄▇▄▇▆▂ + ▂▆█▇▇▁▅ ▁▃▄▅▇▄▁█▅▇▆▂ + ▆▇▄▁▁▁▇▇█▂ ▂▃▁▃▃▇▁▅▃▃▂ + ▅▅▁▆▁█▅▇▆ █▁▇▇▃▇▃ + ▅▁▅▂▁▄▁▃▄▃▁▆ ▃█▃▁▇▃ + ▅▁▁▃▁ █▂▁▁▅▇▃ █ ▃▄▁▆ + █▃▁▄▂ █▇▇▃▇█▁▆ ▆ █▄▁ + ▁▅▃ ▆▁▅▇▃▁▁ ▁▁▁ + ▂▁ ▁▂ ▄▁▅█▃▅▅▇▁▇▇▇▂▂▂▂▆▆ ▁▁▇ + ▁▃▅ ▁ ▆▅▅▅▆▂▁▂▄▃▁▆▅▂▆▅▅▃▄▁▅▅█▁ + ▂▁▄█▃▆▃▃▆▃▅ █▇▇▇▇▇▇▇█▃▆▁▁▁▂ + ▆▁▃█▁▂▂ ▆▃▄▅▇▇█ + ▃▃▆▅▃ ▆▂ ▂▅▃▆▇▄▁▅ + ▁▁▁▄▂▇▇▃▄▄▄▄▆▇▄▇▇▃▁▇▂ + ▇▃▃▁▅▁▂▂▂▂▂▄▆▇▂ + \ 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 new file mode 100644 index 00000000000..d0cd750b8a7 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▄▇▆▂ + ▂▇█▇▅▇▅▁▁▇▁▄▇▄▂▁█▇▆ + ▇▂▅▃▇▁▇█▂ █▃▄▁▇▃ ▆▇▆ + ▆▁▅▁▅▁▅▄▆ ▆▃▁▁▁▁▂ + ▆▃▅▇▅▃▂▇▁▂▃▆ ▂ ▃▁▃▁▂ + ▁▁▁▁▅ ▃▂▁▃▄▅▁▂ ▇▅▁▁ + ▁▅▁ ▇ ▂▃▂▁▃█▁▆ ██▁▇▁▆ + ▁▅▁▂█ ▆▂▄▁▅▂▁ ▇▁▂▁ + ▁▆▁▃▇ ▅▂▁█▆▁▇▄▂▁▇▁▂▂▂▇▆▂█▁▅▁ + ▂▃▁▃▆▆▅▇▇█ ▁█ ▅▅▃▄▃▆▄▅▆▅▁▅▇▁▂ + ▃▆▁▁▆▁▆▃▃▅▇▂ █▇▇█████▆▁▄▁▃▅ + ▃▃▇▅▃▃ ▂ ▅▂▅▇▆▃ + ▃▅▇▃▁▄▇▂ ▂▄▇▆▇█▄▇ + ▃▁▇▁▆▇▇▃▄▁▄▂▆▇▄▇▃▁▄▇ + ▃▆▇▂▇█▂▂▂▆▂▆▇▇▂ + \ 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 new file mode 100644 index 00000000000..2fde73afab1 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▂▅▄▄▄▇▄▄▇▇▂▂ + ▆▃▄▅▇▁▄▇▇▁▇▇▄▂▆▇▇▂ + ▆▆▆▁▁▁▇█ ▂█▁▅▄▇▇▇▅▃▂ + ▅▆▁▇▁▇▂▄▆ ▃▇▁▁▁▁▆ + █▄▁▁▁▇▃▃▇▇▃ ▅▃▃▁▃▆ + ▄▆▅▁▅▂▃▇▄▁▃▂▁▆ ▅▄▃▅▁ + ▂ ▇▁▁ ▃▃▁▅▃▃ ▃▇▁▂▁ + ▁ ▁▁▁ ▂▁▄▁▅▁▁ ▁▇▃▁▁ + ▂▅▁▆▁ ▅█▁▁▇▄▅▇▂▁▇▁▂▂▇▇▂▅▃▅▁ + ▁▁▃▆▅▂▃▂▄▇ ▁▅▄▇▆▁▁▆▄▄▄▃▁▆▁▃ + ▅ ▁ ▅▁▁▁▄▅█ █▇████▇█▂▃▃▅▁ + ▆ ▄ ▅▆▂ ▆▅▂▅▅▁▂ + ▅▂▃▂▇▇▆ ▂▇▃▂▅▇▆▇ + ▂█▁▇▆█▇ ▄▁▂▂▆▇▇▇ ▅▄█ + ▃▆▄▇▇▆▂▂▂▂▂▆▇▇ + \ 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 new file mode 100644 index 00000000000..f9b4ed92190 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▂▅▁▄▇▇▄▄▇▆▂ + ▆▇▂▇▃▁▇▇▇▄▄▃▄ ▂▇▆ + ▇ ▆▁▁▁▇▂ ▂▃▁▂ ▇▅▂▇▆ + ▃▃▁▁▁▁▄▇ ▃▁▃▇▁▅▆ + ▂█▁▇▁▁▃▁▅▃▆ ▅ ▇▁▁▃ + ▁ ▄▁▁█ ▂█▃▄▁▃ ▆▆▁▁▁ + ▂▃█▁ ▃▂▁▃▁▃▁ ▇▇▃▁▁ + ▂█▅▁▁ ▄▇▅▁▇▁▂ █ ▁▁▁ + ▅█▁▇ ▆█▂▅▁▃▅▁▁▄▄▂▁▁▄▄▇▃▁▁ + ▁▆█▁▁▄▅▁▁▅█▁▅▃▁▁▃▃▆▄▆▄▃▁▃▁ + ▃ ▄▃▅▇▁▇▂▅ ▇▇▇▇▇▇██▂▅▁▁ + █▂ ▅▂▅▂▂ ▆█▆▅▄▅█ + █▄▆▇▃▃▃▂ ▆▃▁▆▇▅▇█ + ▃▂█▃▁▇▃▄▁▂▂▂▇▇▁▇▇▅ + █▁▇█▁▅▁▂▂▆▂▄▇▇ + \ 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 new file mode 100644 index 00000000000..44c448de8a3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▂▂▇▂▄▇▇▄▇▆▂ + ▆█▇▅▁▁▁▇▄▄ ▁ █▇ + ▂▁▄▆▁▅▅▇ ▆█▁▅▁▇▃▇▃▆ + ▂▇▃▇▅▁▆▇▁ █ ▄▃▁▁▃ + █ ▅▁▅▆▃▁▅▃ ▅█▃▁▁▆ + ▁█▆▁▃▃▃▂▃▁▂▇ ▃▇▁▁▁ + ▁▇▁▁▁ █▂▄▃▁▂▁ ▁▃▁▅▁▆ + ▃▆▁▁▅ ▁▇▃▅▁▆▇▅ ▅▂▅▁▁ + ▃ ▃▁▁▂▃ ▅▇▄▁▁▅▇▆▇▆▆▃▁▁▁ + ▃▇▅▄▂▁█▆▁▆▁▁▄▁▄▂▂▁▁▄█ + ▅▅▃▃▁▃▁▁▁▅█▃▇▃▇█▃▇█▅▃▁ + █▆█▁▃▁▂▂ ▅▆▅▅▅█ + █▃▂▇▃▃▃▂ ▂▅▃▂▁▇▅▇ + ▅▅█▁▆▇ ▄▄▇▇▄▅▄▆▁▂ + ▁▁▂▇▇▃▁▂▆▇▅▄▂ + ▂▂ \ 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 new file mode 100644 index 00000000000..a18a8a231c3 --- /dev/null +++ b/codex-rs/tui2/frames/hbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▅▄▃▇▅▇▇▄▆ + ▅▇▂▅▁▁▇█▄ ▂▆▃▂ + ▅ ▆▁▁▅▇▃▃▄▅ ▃▂▁▆ + █ ▁▁▅▄▄▂ ▂▃▆▁▃▃▁▇▆ + ▅▇█▄▃▇█▁▇▆ ▇▅█▇▅▇▁▁ + ▁ ▁▁▁█▃▁▃▄▂▂ ▁█▁▇▁▁▁ + ▂ ▁▇▃▁▇▇▁▃▃▆█▅▆▅ ▂▁▁ + ▁ ▁▅▂▆▃▁▁▁▂▅▄▃▂▁ ▁▁ + █▆▁█ ▅▁█▁▁▁▁▇▁▁▆▁▁▂ + ▁▂▁▁▁▂▆▁▃▁▂▁▁▁▁▂▂▁▁▁ + █ ▂▃▅▃▃▁▅█▇▇▇▇▁▁█▅▃▁ + ▃▁▁▁▃▆▂ ▆▅ ▃▅▁▂ + ▃▇▃▁▃▃ ▅▄▂▄▁▅▇ + ▃▆█▃▂▁▇▅▇▄▄▄▇▁▂ + █▆▂ ▇▃▃▁▄▇▅█ + \ 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 new file mode 100644 index 00000000000..1019a11c958 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_1.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..942f59e944f --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_10.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ef0aff76e0f --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_11.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..8940e05bd67 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_12.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c73afab740d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_13.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..8a273a1666a --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_14.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..5a0e8f1b549 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_15.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..06c519f6028 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_16.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..0bd4ef6dfc5 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_17.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..de59f344efe --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_18.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ade56623593 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_19.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..be49360bbf5 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_2.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..6eaf358e88d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_20.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..5f317f375c5 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_21.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..74b75b91135 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_22.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..35e7fe2210d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_23.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..a74ea1f0bb7 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_24.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c2c5b30b296 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_25.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..09a947d35d6 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_26.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..b3fef11ac8c --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_27.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..11fdcec5207 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_28.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..2dc6c667532 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_29.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..9026d59a430 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_3.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..73b4906d0ec --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_30.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..cc71fce9200 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_31.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c0d6573da78 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_32.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..56ef96d36a8 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_33.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..b6e87c62f1c --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_34.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..899d6766b79 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_35.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..9a23d2ddd6d --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_36.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..0c76cc5ce83 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_4.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..2b06cade095 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_5.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..2ca8bb0bc79 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_6.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f66ddaf5a65 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_7.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..e54163d2c8a --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_8.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..a339de11184 --- /dev/null +++ b/codex-rs/tui2/frames/openai/frame_9.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..244e2470b4f --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_1.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□□●●□▲◆ + ◆●▲△□○□△□○●◇◇●◆ ◆■□◆ + ▲◇□◇□□□■○◆ ◆■□◆■◇●◇◇◇□ + ●□◆○□■▲▲◆ △□◇●◇▲ + ○○●△■○◇○◆○○ ■△◇○○▲ + ◇□ □◆ ◇□△●◇◇▲ ■△○◇◇▲ + □○■▲□ ■○◇□△■◇◆ ◇△◇ + ◇◇◆◇◆ ▲△△◇●◇□ ■◆◇ + ◇●◇■◆ ●◇◇○○◇■△◇□□□□□□◆□▲ ●■ ◇ + ◆◇●□ ◆●□◆ △□ ◇■◇◆◆◆△△▲▲▲◇△▲△▲◇ + ○○◆■○ ○○▲△△◆ ◆○□■■□ ○□■△▲●◆△ + □○▲ ■▲ ◆ ▲■△□◆◇ + ○○▲◆○□◆ ◆●◆□◇◆□■ + ○□▲○◆ □□△●●●●△●□□◆▲◇□ + ◆□■□◇◇◇◆◆◆▲◆●□□■ + \ 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 new file mode 100644 index 00000000000..f306dffc087 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_10.txt @@ -0,0 +1,17 @@ + + ◆□□□□○□□◆ + □■◆□□□○◇△◆□▲ + ○◆▲◇◇△◇◇▲◇□○▲▲ + ◇◆◆△○◇●◆△○▲■■○○▲ + △ ●◇◇■◇○ ■▲△◆△◇△ + ◇◆ ■◇□◇△△○ ◆◆■◇ + ○ ◇□■□◇◇◇◇□ ◇△▲ + ■ ◇◇○□△□◇◇▲◆◆△○◇◇ + ■ ◇○ ○○◇●◇◇□○□●◇◇ + ◇ ▲◇○▲◇◆△◆□◆◆◆◇□◆ + ▲ ■◇◇◇◇◇■■ ○▲■○◇◆ + ○◆■▲○▲□■ ◆◇■■▲△△ + ◇■ ◇◇◇◇□▲△▲△◇△◆ + ●◆□□△◇□●◆ △△■ + □▲ ◆□○◆▲●□ + \ 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 new file mode 100644 index 00000000000..dcf944902b3 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_11.txt @@ -0,0 +1,17 @@ + + ▲□□□□□□◆ + △■ ●◇◇◇○○▲ + △■◆◆◇◇●○□△○▲ + ◇◆◆◇◇●●▲◆●△△ + ◆■△●○◇□○■●◆◇◇▲ + ◇□◆◇◇□◆●●◇◆△◇◇ + □□ ▲◇◇◇◇◇◇◆◇●○ + ◇ ◇◇△◇◇○△ ◇■◇ + △ ■◇△◇◇□△□◇◆◇ + □□ ◆◇△●▲■△◇◆◇○ + ■◆▲ ◇◇◇◇●△◇○○◇ + ○◆▲△◇◆□△□□●◇◆ + ◆ □◇○○○◆◇●■ + ○□ □◇◇▲◆△□◆ + ○◆ ■□□◆□◆ + \ 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 new file mode 100644 index 00000000000..d8d1fbf334f --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_12.txt @@ -0,0 +1,17 @@ + + □□□□□△ + ▲●◆◆△◇▲○ + ■ ◇△○□▲ + △□◇◇◇●●■◇ + ◇ ■◇◇◇◇△△◇ + ◇■△△□○■◆◇■ + ◇ ◇△○◇◇○ + ◇ △□○◇◇■ + □▲ ◇△◇◇□◇◇ + ◇ △◇□●□◆ + ◇△ ◇■●□□△ + ▲ □◇ ◆▲◇ + △ □□◇▲□○◇ + ■○△■▲◇○◇◆ + ○ ■◇○△△ + \ 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 new file mode 100644 index 00000000000..1387fc9b912 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_13.txt @@ -0,0 +1,17 @@ + + △□□□▲ + ◇◆◆◇◇ + ◇◆◆◇■ + ◇□□◇◆ + □ ◇◇ + ■△▲◇◇ + ▲ ◇◇ + □ ■◇ + △□ ◆◇△ + ◇■ □●◇ + ■◆△△ ◇◆ + ◇◆◆◆◇ + ◇□▲◇◆ + □◆◆◇● + ▲ △■● + \ 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 new file mode 100644 index 00000000000..70a5070ba9b --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_14.txt @@ -0,0 +1,17 @@ + + □□□□● + ▲△◆◆△○ + ◇◆◇◇△ ◇ + ◇◇△◇■○◇▲ + ◇◇◇○ ◇ + ◇□◇◇ ■◇ + ◇◇◇◇ △ □ + ◇◇○◇◆ ○ + ◇◇◇◇ ■ + ■○△△ ●△ + ○◇■■▲△▲◇ + ◇□◇◇◆◆◆◇ + △ ■◇●●●◆ + ◇○△□◆◆△ + ◆□ ■●△ + \ 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 new file mode 100644 index 00000000000..584e0e043a9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_15.txt @@ -0,0 +1,17 @@ + + □□□□□◇◆ + ◆▲●◇◇○△□▲ + △△◇□●○◇■□◇ + ◇△◆ △△▲△◆◆◇ + ◇◇●■●○◇◇△■○◇ + ○◇◇▲◇◇△△◇ ■ + ◇◇ ◇◇◇◇◇◇▲◇ + ◇◇△◇◇□◇◇◇ ■▲◇ + ◇◇□▲▲□◇◇◇◆△△◇ + ◇◆◇●△◇○◇□△ △◇ + ◇△◇◇◇◇◆◇ ◆ ● + ◇□□△○◇◇○▲ ● + ■◇○▲ △△△◆ ●◆ + ◇○◇◇○○△□○△ + ○○□○◆◆◆△ + ◆◆ \ 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 new file mode 100644 index 00000000000..af6c8368553 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_16.txt @@ -0,0 +1,17 @@ + + ◆●□■□□□◇◆ + △○◇◇□◇◇○◇●○ + ◇△△■○△●○◇◇ ■○ + △△△ ●□◆○◇○◇◇□□◇ + ◇◇●◆△□■●◇◇□△◇◆○ + ◇◇◇□◇■ ◇○● ◇ ◇△◇ + ○○◇ △◆▲◇◇◇◇△□■▲ ◇ + ◇△● ◇◆◇◇△◇◇◇■● ◆◇ + ○○◇◇◇□◇△○◇◇■□□ △ + □■◆◆▲●●○□◇△◆◇ ▲◆◇ + ◇◇◇□■■△□○◇●■●△◇◇◆ + ◇◇□ ◇◆ ◆△△▲ △ + ○◇□ ◇ △▲△◆▲◇ + ○◇◇■◆□◇△△□◆◇ + ■△△◆●△◆◆●□ + \ 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 new file mode 100644 index 00000000000..4a158cf6094 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_17.txt @@ -0,0 +1,17 @@ + + ▲●□□□●●◇▲◆ + ◆●□□◇◇◇□●□◇▲■○▲ + ▲□○△◆●●◆□▲■◇◇◇◆■○ + △◇△◆△◇■◆◆ ■◇●○◇◇◇●○ + ◇△◆ ◇ ◇■△○●○△△ ▲ + ◆●□▲△◆ ▲△△◇ △▲●△◇△△ + □ ●△◇ ▲△△◇◆ □■◇◇△●◇ + ▲■●◇ △△◇◇△◇▲◇◇●●□ + ▲△□△○●●□◇○◆◇○○△○◇◇◇ ◇ + ◇■◆●◆◆◇△□○△◇◇○ ○□■□○ + ○○○△■■■□□□□○◆◇□△△○ ○■ + ○◇◇◆□ ◆△◇◇◆ ● + ◆○○ □○◆ ▲■▲△○◆△ + ▲ ◇□□●○□◆◆●□△◇■ + ◆□●△◆▲□◇□◆□□ + ◆ \ 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 new file mode 100644 index 00000000000..16bf8c1b581 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_18.txt @@ -0,0 +1,17 @@ + + ◆●●□●●□●◇▲◆ + ◆□■◆●▲□□◇◆◆▲■□●●◆ + ◆△△◇□■□●■▲■△◆■□△△○ □ + ▲◇◇□△▲○■□ ■○◆◆△●◇○●○○○▲ + ◆○△△ △◇ ◆■□◆○△◇△◇◆○●○○◆ + △○◇ ▲▲◇△◆◆◆△▲□▲◇■▲△○△◆△◇ + ◇◇○△△ □△□△◇■◆△■□◇●◇●▲ + ■○▲◇◇ ○○■◇◇●○ ◇●◇■ + ○■■ ▲○●●●●●□◇◇▲◇□△○▲ ◇□◇◆ + ◆○○◇□○□ ◇◇△◆◆○◆◇◇◇◆●◇◆○ ▲ + ◇△◇○◆■■□□□□□◆ ○◆◆△◇△□△◆▲◆ + ○○ ▲○▲ ◆◆□■△□◆■ + ○○□□○○△ ◆□○◆△○▲◆ + ■◇△□□□□●●●○■□◆△◆▲□ + ■◇◇◆△◆◆▲△□■●□■ + ◆ \ 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 new file mode 100644 index 00000000000..e1bc51ae1be --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_19.txt @@ -0,0 +1,17 @@ + + ◆◆●●□◇□●●◇△◆ + ◆□◇◇◇○△●●●◆□◆▲◇□▲▲ + ▲□●●△■◇□□◆ ◆□△▲◆□●○ + △●◇□▲○◆ ◆●●□▲○◇□▲ + ◇◇△◆△◆ ▲◇○■△■◇○ ◇○▲ + ◇○△□■ ◆◇□◇△ △△ ■△□◇ + ○●◇◇■ △□○△◇□▲◆ △△△□◇ + ◇◇◇◇ ◇●■○◇■▲ ◇ ◇◇◇ + ◇□◇○▲●●●●●●◇◇◇◇□□●□◇● ○ ■◇◇ + ○●△▲◇●□△○□□○▲◆▲ ○◆ △○◆○▲△△◇◆ + ■○■△□◇■■■■■□□■ ○○◆△◇△△ △△■ + ○○○□◆○ ◆△△◆△△■ + ■ ◆○◇◇□▲ ▲●●△△◇●◆ + ■■△◇□△◇□◆◆●●△□■ ●□◇■ + ■◇△◆△▲■◆▲△○□▲○ + \ 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 new file mode 100644 index 00000000000..af71459f5e9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_2.txt @@ -0,0 +1,17 @@ + + ◆△◆△●□●●●□▲◆ + ◆□▲△□●□◇◇◇●◇◇●◆■◇□□◆ + ▲◇□◇○□□◇□◆◆ ◆■□●□△◇◇●◇▲ + ◆◇△◆△□■▲▲◆ ▲▲○◇△◇▲ + ▲○■△□○◇○◆○○◆ ○▲◇○◇▲ + ■△□■◆ ◇□○□△○□ ○○◇□◇ + ▲◇■□◇ ○△◇●◇◆◇▲ △■◇■◇ + ◇■ ○◆ ▲◇□◇◇●□ ◇■△△◇ + □■■●◇ ●◇○○◆◇■△◇□□□□□□□□▲◇◆◇◇□ + ◆●■◇ △◇◇■ △□ ◇●○◆◆◆◆△△◆◆◇□△ ◇ + ○○○ □ □○△△◇■ ◆■□□□□□□□■◇▲□◇◇ + ■○◆△△▲ ◆ ▲□●△▲□ + △○◆◆◇□▲ ▲●◆□ ◆◇■ + ◆◇○●◆◆■□□△●●●●△●□◆◆◇◇□ + ◆□□□□◇◇◆◆◆◆◇●△□■ + \ 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 new file mode 100644 index 00000000000..c5eb01382d6 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_20.txt @@ -0,0 +1,17 @@ + + ◆◆□●◇◇●□●▲◆◆ + ▲●●■●▲◆▲●●●◇◇◆○□○□▲ + ▲▲■◆□□■◇□■ ◆■○■○□□◇□◆ + ◆◇■◆□◇□ ◆▲□◇○△□○○ + ◆◇ ▲◇□ ◆△ ◆◇◇○□○◇◇○ + △◆▲◇■ □□■●◇■△ ■○◇◇○ + ●◇●□◇ ▲□ △□□○ ▲○◇△ + ◇◆○△◆ ■■●△◇■▲ ◇◇■◇ + ◇△◇◇●▲●●●●●●●●◆▲■●○○○○□ △ ◇○ + ■◇○◇◆■ △◆◆◆◆◆◆◇○ □○○■◇●○ ▲△ ◇◇ + ○▲○○■■■■■■■■□■ ○▲●◇◇▲◇ △○ + □ ○□△▲ ◆△□△◇△ + ○◆○□▲◇◆ ▲●□◆●□■ + ○▲▲△■◇□○●●▲△■◇■◆▲△□■ + ◆□□◇◆ ●◆◆◆◆◇□■■ + \ 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 new file mode 100644 index 00000000000..944b99f0581 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_21.txt @@ -0,0 +1,17 @@ + + ◆◆▲●□●●□●□▲◆◆ + ▲□□◆◆◆◆□◇●◇□●◆◆▲□□◆ + ●□○◆□□○■◆ ◆○◇□□◆◆□▲ + ▲□◇●■■ ◆▲●□▲●□▲ + △▲△□◆ ▲◇△◆○ ○○◆○▲ + △■△◇ ▲□□◆△△ ○○○▲ + ◇■◇◆ ◆ △◆▲△■ ○●○■ + ◆○ ○● ■◇▲ ◇ ◇ + ◇ ◇ ▲□●●●●●□□□ ○◇▲■◇▲ ●△ ◇ + ◇ ◇○ ◇◆ ◆▲◇ ○■◆□◇▲ △■△△ + ● ■△ ◆◆■■■ ■◆ ■□◆▲● △◆ ▲ + ■□ △●◆ ◆□■ △ + □◆ ○▲◆ ◆△●□■ ▲◆ + □●◆ □■□□△◇◇●△●□■■ ▲◆ + □□□●□◆◆◆◆▲◆●□□◆ + \ 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 new file mode 100644 index 00000000000..60ea930d46d --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_22.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●●□●□▲◆ + ◆□△□◇△◆△◇△○◇◇●◇□△□□◆ + ▲●□●◇○□□◆ ◆■□○□□□◆◆□▲ + ●□◇□□■ ▲▲◇◇◇◆□○ + ○◇◇△■ ▲◆△◇●◇△△●■◇ + ○◇□△◆ ▲◇△◇●○△◆ ○◇○●◇ + ◇□□○△ ◇□◇○□△■ ◇◇△◇ + ◇◇●◇◆ ◇○◇◇○○ ◇○◇■◇ + ◇◇▲■▲ □◇●●●●●●□□◆○□◇◆ ○▲ ◆◇●■ + ○◇● □◆○○▲ △▲■◇△△◇ ○□●◆■◇▲▲ △○● + ○○○◆◇ ■■■■■□□■◆ ◆△□□◆△△△■▲◆ + ○□○◆○▲ ◆□▲■◇▲■ + ■■□● □□◆ ◆△□▲■□◆△ + ■△□□▲■□◇●●□●●△●□■■■▲□ + ■◇■◇◆●◆◆◆▲●◆△●□◆ + \ 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 new file mode 100644 index 00000000000..5d340640bf3 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_23.txt @@ -0,0 +1,17 @@ + + ◆▲□◆●□□□□●□◆◆ + ◆●◇□◇○◆▲◆●●▲◇◆◇□◇□◆ + ▲■□◇■▲●□◆ ■◇△□◇○●◇◇▲ + △●◇□●△◆ ◆●●○◇◇●○ + △△◇◆△■ ▲△▲▲■□△□○◇○ + ▲○◇■△◆ ◆□□□■■△□■◇◇○◇▲ + ◆◇◇■◇ △△△△ ◆◇◆ ■ ◇■□ + ◇△◇ ◇ ◇■◇▲◆○▲ ◇ □△◇ + ○■○ ◇□◇●●●●●●●●◇◆□◇◆■○◆ ◇ ◇◇◇ + ◇○○◆◇■●■ △◆○ ○◇◆○▲◇ ○◆◇ + △■◇ ◇ ◆■■■■■■■ ■◆△●●◇◆△○△◆ + ▲◇◇◆◇△ ▲△◇○○△◆ + ○ ◇▲■△◆◆ ◆△■●□△□△ + ○●○□●□□○◇●●◆□□■▲◆□□◆ + ■◇ □●○◇◆◆□◆◆□□◆ + \ 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 new file mode 100644 index 00000000000..558224147dc --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_24.txt @@ -0,0 +1,17 @@ + + ◆◇◆●□□□●●▲◆ + ▲■◆△■◇◆◆◇●●□▲◆■□ + ▲■■●○△□■◇ △◆△□◇□□▲ ○ + ▲■△△▲△◇●▲○◆◇△◆ ◆□○◇○◆■ + ▲ △△○△▲ ◆■■△○◆◇◆◆○◇▲■ + ◇▲○□○◆ □▲▲△ △◇● △■○ + ◇ ◇◇ ▲△◆▲△■△○○◇○○◇○ + ◇ ○◇◇ ◇▲●△○◆◇◇◆△△◆◇◇ + ◇△ ◇●●●△□□◇◇◇□□◆□▲◆◇ ◇ ◇◇ + ◆ △ □◇△▲▲●●▲▲◇△○○□□◇ ◇□◇ + ○ ■○◇○□■■■■■■◆ ○▲△□○◇●△▲◆ + ○ ○▲□○ ◇ ■△●△ + ○△□○◆◇△ ◆●■◆△△◆□ + △▲□▲○○■◇◇△●●●◇○◆△◆ + □△◆□□●○◆◆●●□□ + \ 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 new file mode 100644 index 00000000000..38d32507640 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_25.txt @@ -0,0 +1,17 @@ + + ◆□●□●□□□□◆ + ▲ □▲□◆◆●○◇□◇◇▲ + △◇△△▲▲■△◇□○●○◇◇◇◆ + ◇□□◇ △ ●◇ ■○◇▲□○△ + ▲◆◆○▲△■○●△◆◇■ ◇△△○△□ + ○▲◇◇◇◆□□△○◇△ △◇▲◇△△◇▲ + ◇△◇□◇ ◇◆◆△○◆△◇○△◆■◇●◇ + ◇◇◇△◇△■ ◇◆●○△◇○◆◇●◇◇○ + ○□○◇■◇◆◆●●●●□◇◇○○◇□□◇○ + △▲■◇△◇▲◇◆◇◇●□◆◇○□●△○◇ + ○◇□△○◇◆■■■■■■□△◇○◇△◇◆ + ◇◆■◇●□●○▲▲□△◆△▲△△◇△ + ○◆○◇◆○◆□●△▲○△◆◇◇△ + ○△●○○◆□●◇□□ □◇□ + ◇▲■□○□▲◆□◇□ + \ 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 new file mode 100644 index 00000000000..4aac44389a9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_26.txt @@ -0,0 +1,17 @@ + + ◆●□□□■□▲ + △□◇●△○◇□●◇▲ + △□■■◇●○◇○■■○▲ + ●□■▲◇◇◇◆◇◇◆□△○ + ◇◆▲○◇△◇■□○△●◇○● + ○ ■◇△△△■□◆○■○○△ + ◇ □■●◇◇△◇◇●●■□ + △▲ ○◇◇◇○○●◇◇△◇◇ + ◇ △○□●□◇□●●△○◇◇ + △◆◇△▲◇◆◆▲□○○□◇ + ◇◆■△◇□■○▲△◆□○◇◇ + ○◆◇◇△○ △△■◇◇◆ + □ □◇○ ○◆△◇△▲ + ◇■□○○■□◆□□● + ○△△○□◇◆○□ + \ 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 new file mode 100644 index 00000000000..9896590f797 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_27.txt @@ -0,0 +1,17 @@ + + ●□□□□▲ + ◇●△△◇△○ + ●■◆ □◇△■◇ + ◇◇◇○◇◇●◇◇ + ◇ ◆◇◆▲■● + ◇◆△◆■◆◇◇◇◆ + ● ■◇◇◆□◇◇ + ◇ ◆◇◇◇●■◇◇ + ◇△ △ ◇□◇◇◇◇ + ■● △■◆◇◆□◇◇ + ◇▲ ◆○□□□△■ + ◇◇□□◇◇◆△◇ + ◇●●□●▲△◇○ + ○◆◆◇○●△△ + ■○■ ○○△ + \ 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 new file mode 100644 index 00000000000..16b349dc3d5 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_28.txt @@ -0,0 +1,17 @@ + + △□□● + ◇□◆◇◆ + ● ●◇◆ + ◇▲◆□◇ + ◇ ◇◇ + ◇ ▲■◇ + ○◇◇ ◆◇ + ◇▲■■◇ + △○ ◆◇ + ◇◇△△◇◇ + ◇ ◆◇ + ◇●□●◇ + ◇●□◇◇ + □◆◆△◇ + ◇ △□◇ + \ 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 new file mode 100644 index 00000000000..24be1563b27 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_29.txt @@ -0,0 +1,17 @@ + + □□□□□▲ + ▲◇□△ □○ + ○▲◇◇ ■■◇ + ◇◇◇◇○○▲△ + ○ ◇■◇ ◇ + ◇▲◇◇◇ ◆◆ + ◇○○◇◇◇ ○ + ◇◇●◇◇ + ◇◇◇□◇ ◆ + ◆◇●◇◇ ◆ + □◇◇△◇○ ● + ◇△◇◇◇ ◆◇ + ◇◇◇◇◆◇◆◇ + ◇●◇□△■◇■ + ◇◇□ △▲ + \ 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 new file mode 100644 index 00000000000..3f55b79ac59 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_3.txt @@ -0,0 +1,17 @@ + + ◆△●●□□●●●●▲◆ + ◆□▲△○◇◇□◇■●●◇●◆○◇□□◆ + ▲●△□●◇□■■◆ ◆■◇ □□□△○◇▲ + △□△○◇●▲□◆ ◆●□ ◇◇○ + ■△□◇△○△◇◇□○◆ ○○○◇◇◆ + △◇○○△ △▲◇○●○○ ◇ ○○◇ + ●□ ◇ ■□○◇◆■◇▲ △□△◇▲ + ▲▲◇▲◇ ▲▲◇■△○◇ ◇◆◇●◇ + ▲■■◇ ●◇□□◇◇□▲◇●□□◆◆◆□□◆◇○■●◇ + ▲○△○▲ △△□△●●◇ ◇□◇◆◆▲ ●△▲●○◆◇○◇◆ + ■○◇ ◇ ■◇◇□▲■ ■□□□■■■□□▲△△△▲△ + ■□◇ ○□ ◆ △○□○△△ + ●○△△□○▲ ▲□●△■▲◇■ + ●○□▲◆□□○●●●●▲△●□●◆◆□■ + ◆□□□◆△◇◆◆◆◆◇●△○■ + \ 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 new file mode 100644 index 00000000000..54886a319d0 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_30.txt @@ -0,0 +1,17 @@ + + ◆●■□□□▲ + ◆△●◇◇○□■●○ + ■■○◇▲○○◇□▲○ + ◇◇○◇△◇◆○◇○ ○▲ + ●□◇◇◆○◇▲◇▲ ◇ + ◇□◇○□ □◇◇◇◇△◇ + ○△◇◇◇○◆ ○△○◇ ●◆ + ■◇ ■□●◇▲◇■◇▲ ◆ + ■◇◆■○◇●◇○△◇▲ ◆ + ■◇◇○◇◆◆◇◇ □▲ ◆ + ○◇◇●○◆□◇◇◇□◇◇ + ◇□△△▲△□○◇◇■ ◇ + ○◇○◇◇△□◇◇▲◇△ + ▲○▲□●○△◇▲△■ + ○▲△◇○◆◆●■ + ◆◆◆◆ \ 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 new file mode 100644 index 00000000000..b3989b89df9 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_31.txt @@ -0,0 +1,17 @@ + + ◆△△□□□□●▲ + ◆□□●□▲◆■□○■□▲ + ▲◇○△△▲●◆◆△■◇◆□○ + ▲◇△□◆◆□●◆▲○○◇○◇◇○ + ○◇◇◇◇□▲□○◆▲■○◇△◆◆ + ●◇○◆◇◆◇■■○●□▲△△◇▲■◇ + △◇◇ ◇○□◇○△●△●□◇□◇△◇ + ○○▲ ◇◇■◇○ ◇○■■◇◇○△◇ + ◇○▲◆◇■○◇◇◇△□◇◇◇◆ ◇ + ■◇◇◇△△◇◇◆◆◆○◆◇○◇◇ □ + ○□◆△□◇▲ △□□□◆△◇■◇◆ + ◇◇◇○○○▲ ◆●□ ◇ + ●◇□●◆ ▲ △ ◇□◆△◆ + △◇○◇△◆◇◆▲◇■▲◇◆ + ■■●◇◆□□△△●□ + ◆◆◆◆ \ 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 new file mode 100644 index 00000000000..919eee3b0fd --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_32.txt @@ -0,0 +1,17 @@ + + ▲□□□□●●◇▲◆ + ●□◇□□◇○◇◇□□◇◇▲ + △○□●○▲□○△▲◆□◇□○■□▲ + ■△△◇◆▲◆●▲ ▲ ▲◆○◇□▲ ▲ + △△◇▲◇□○■□◇● ■◆▲◇○△○□○▲ + ○△○○▲○□◇◆△◇◇□◆ ◇□△ □ + □◇■●▲◆■○◇◇▲▲◇▲●◆△◆△△◇■◆ + ◇◇◇▲◆ ■◆◇◇■△■◇●▲◆■○◇■■◆ + ○◇◇▲◆ ■◆◇■◆◆◇□□□◇◇◇●◇■●◆ + ■■■◆◆◇□○■○◆◆◆◆◆◇◇□◇●◇▲◆ + ▲○□◇◇△ ◇□■■□□□○●△△△△△ + ○▲◇○○○ ▲○◇△■◇ + ■□□◇◇◇□◆ ◆△◆◇△ ▲ + ○▲○■◇□◇●●□◆◆□■◆△ + ◇●●◆◆▲□△□◆■■ + ◆◆◆ \ 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 new file mode 100644 index 00000000000..c5598aa7a73 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_33.txt @@ -0,0 +1,17 @@ + + ◆▲□●□●□●◇△◆ + ◆□□◇◇○□●◇△◇▲◇□◇□△ + ◆△○◇□□□▲●■◆ ■□△◇◇□■▲ + ▲○△◇△◆◇▲◆ □◇◇○◆□ + ◆△◇△◆◇◇△○○○ ◇□○○○ + △△▲ ○◆○○◆○▲ △○△◆○ + ◇◇□ ○▲□◇◆◇○ ◇□◇◇ + □◇▲▲ ▲■■○△●▲ ○■◇□ + ◇◇◇ ◇ ▲◆ △□▲▲▲●●●□◇◇□△◆◇○◇ + ○△◇■■△△▲△◆◇◇◆◆◆◆◆◇◇□○○○◇◇△ + ○●○▲ ◇△◇△ △ ■■■■□□□ ◇◇▲△○◆ + ○□○□▲■▲ △△●△●△ + □○■□▲□●▲ ◆□◇▲△□○■ + ○◇■□○□◇▲●□◆■●▲○□◇□ + ■●●□▲◆◆◆○□□●▲■ + ◆◆◆◆ \ 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 new file mode 100644 index 00000000000..5a44de82561 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_34.txt @@ -0,0 +1,17 @@ + + ◆▲●□□□□●◇▲◆ + ◆□□□△○●◇■◇□□●□□●▲●◆ + ◆△△▲◇□■△◇□◆ ◆○○○▲■□□▲ + ▲△◆◇□◆△▲◆ □◇▲□○○ + △△◇○▲△◇ ◇■ ▲ ○○□○○ + ▲△○◇◆△ ○○■◇○□○ ○▲○◇○ + ◇□○◇◆ ■○◆□◇◆○ ◇●◇◆ + ○◇●■△ ◇□◆◇△■◇ △ ◆○ + ▲■△△■ △○△△■◆●●□□□□□□◇◆◆■ ◇○◆ + ◇◇○▲△▲▲○■△△■◇◇◇◆◇△◆◆◆▲△◆◇◇◆◇○ + △◆○▲ ◇○◇□○ ■□□◆◆◆◆○ ▲△▲○◇■ + △▲○●●○◆ △●△◇△■ + ○□◆◇□○●▲ ◆□●■◆△▲◆ + ▲○△◇■◇□▲●▲□●□●□◆□□△■ + ■◇○●●◆◆◆◆◆●●□●○ + ◆◆◆ \ 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 new file mode 100644 index 00000000000..1c1728676b2 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_35.txt @@ -0,0 +1,17 @@ + + ◆▲□●●□□□●□▲◆ + ▲□●□△◇□□□■◇□◇△■◇□●◆ + ◆△△◇●□□◇○□■◆ ◆■□●□△■◇□▲ + ▲△△□△▲●▲◆ □□◆○◇◆ + △△◇◇△△○ ■○□▲ ◇○□◇▲ + ●△●△□◆ ■◇◆■△△□◆ ◇○○◇ + ○ △△ ○○ ■□◆△ ◇○○○ + ◇◇◇△ △○○●△■ △ ■△ + △ ▲◆ ▲◇◇△●■△◇●●◆●◆●□◇◇ ◇◇●□ + ◇▲○●■ △△●■□□△◇● ○▲◆◆◆◆△■◇△■ ◇■ + △◆○▲■△○◆◇△○◆ ◆■○○○○□○○□△△◆▲◇ + ■△○○△◇ ◆△□ ▲○ + ○●◆○●▲△▲ ▲●△◆□□■ + ■○◆■◇□◇▲△◇◇□◇●△□■◆△●■ + ■○◇●●◆◆△◆◆□□□■○ + \ 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 new file mode 100644 index 00000000000..0cac995ed7a --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_36.txt @@ -0,0 +1,17 @@ + + ◆◆□●□□□□●□▲◆ + ◆●■■○□△●△□○□●□□■□□□◆ + ▲○■○▲□□▲□■◆◆ ◆■□◇□□◇▲□◇▲ + ◆◇■□□◆◆◆◆ ■□◇○■◇ + ▲△◆●○■◇○◆○▲△ ○○△□ + △ ▲△◆ □◆ ■◇△▲ ■○○□ + ◇ ● ◆ ○ ■○○ ◇○◇▲ + ◇◇○◇ ◆○ ◆△△ ○◇ ◇ + ◇■■◇ △△ ▲□■■▲□□◆□□□□□◆ ◇○◇◇ + ◆□◇○ ▲◇◆◆△◇△ ◆◇◆◆◆◆◆◆◆◇◇ ○△■◆ + ■ □◇○ □◆□△○ ■□□○○○○○■ ◇▲■△ + ■▲■○●△ ▲△◇■△ + ○△◇◇□◇▲ ▲□□■△▲◆ + □▲◆■□□●○●◇◆□◇●■▲■▲●□■ + ◆□□△◇◆▲▲◆▲▲□□□□■ + \ 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 new file mode 100644 index 00000000000..31e55f9cb8c --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_4.txt @@ -0,0 +1,17 @@ + + ◆△●●□●●□●□▲◆ + ◆▲■□□◇△ ◇○●△□●◇■△□▲◆ + ▲□●◇◇◇□□■◆ ◆○◇○○□◇△○○◆ + △△◇▲◇■△□▲ ■◇□□○□○ + △◇△◆◇●◇○●○◇▲ ○■○◇□○ + △◇◇○◇ ■◆◇◇△□○ ■ ○●◇▲ + ■○◇●◆ ■□□○□■◇▲ ▲ ■●◇ + ◇△○ ▲◇△□○◇◇ ◇◇◇ + ◆◇ ◇◆ ●◇△■○△△□◇□□□◆◆◆◆▲▲ ◇◇□ + ◇○△ ◇ ▲△△△▲◆◇◆●○◇▲△◆▲△△○●◇△△■◇ + ◆◇●■○▲○○▲○△ ■□□□□□□□■○▲◇◇◇◆ + ▲◇○■◇◆◆ ▲○●△□□■ + ○○▲△○ ▲◆ ◆△○▲□●◇△ + ◇◇◇●◆□□○●●●●▲□●□□○◇□◆ + □○○◇△◇◆◆◆◆◆●▲□◆ + \ 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 new file mode 100644 index 00000000000..a8ae0ab8193 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_5.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●●□▲◆ + ◆□■□△□△◇◇□◇●□●◆◇■□▲ + □◆△○□◇□■◆ ■○●◇□○ ▲□▲ + ▲◇△◇△◇△●▲ ▲○◇◇◇◇◆ + ▲○△□△○◆□◇◆○▲ ◆ ○◇○◇◆ + ◇◇◇◇△ ○◆◇○●△◇◆ □△◇◇ + ◇△◇ □ ◆○◆◇○■◇▲ ■■◇□◇▲ + ◇△◇◆■ ▲◆●◇△◆◇ □◇◆◇ + ◇▲◇○□ △◆◇■▲◇□●◆◇□◇◆◆◆□▲◆■◇△◇ + ◆○◇○▲▲△□□■ ◇■ △△○●○▲●△▲△◇△□◇◆ + ○▲◇◇▲◇▲○○△□◆ ■□□■■■■■▲◇●◇○△ + ○○□△○○ ◆ △◆△□▲○ + ○△□○◇●□◆ ◆●□▲□■●□ + ○◇□◇▲□□○●◇●◆▲□●□○◇●□ + ○▲□◆□■◆◆◆▲◆▲□□◆ + \ 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 new file mode 100644 index 00000000000..e0b1f854547 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_6.txt @@ -0,0 +1,17 @@ + + ◆△●●●□●●□□◆◆ + ▲○●△□◇●□□◇□□●◆▲□□◆ + ▲▲▲◇◇◇□■ ◆■◇△●□□□△○◆ + △▲◇□◇□◆●▲ ○□◇◇◇◇▲ + ■●◇◇◇□○○□□○ △○○◇○▲ + ●▲△◇△◆○□●◇○◆◇▲ △●○△◇ + ◆ □◇◇ ○○◇△○○ ○□◇◆◇ + ◇ ◇◇◇ ◆◇●◇△◇◇ ◇□○◇◇ + ◆△◇▲◇ △■◇◇□●△□◆◇□◇◆◆□□◆△○△◇ + ◇◇○▲△◆○◆●□ ◇△●□▲◇◇▲●●●○◇▲◇○ + △ ◇ △◇◇◇●△■ ■□■■■■□■◆○○△◇ + ▲ ● △▲◆ ▲△◆△△◇◆ + △◆○◆□□▲ ◆□○◆△□▲□ + ◆■◇□▲■□ ●◇◆◆▲□□□ △●■ + ○▲●□□▲◆◆◆◆◆▲□□ + \ 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 new file mode 100644 index 00000000000..7e69d68d573 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_7.txt @@ -0,0 +1,17 @@ + + ◆△◇●□□●●□▲◆ + ▲□◆□○◇□□□●●○● ◆□▲ + □ ▲◇◇◇□◆ ◆○◇◆ □△◆□▲ + ○○◇◇◇◇●□ ○◇○□◇△▲ + ◆■◇□◇◇○◇△○▲ △ □◇◇○ + ◇ ●◇◇■ ◆■○●◇○ ▲▲◇◇◇ + ◆○■◇ ○◆◇○◇○◇ □□○◇◇ + ◆■△◇◇ ●□△◇□◇◆ ■ ◇◇◇ + △■◇□ ▲■◆△◇○△◇◇●●◆◇◇●●□○◇◇ + ◇▲■◇◇●△◇◇△■◇△○◇◇○○▲●▲●○◇○◇ + ○ ●○△□◇□◆△ □□□□□□■■◆△◇◇ + ■◆ △◆△◆◆ ▲■▲△●△■ + ■●▲□○○○◆ ▲○◇▲□△□■ + ○◆■○◇□○●◇◆◆◆□□◇□□△ + ■◇□■◇△◇◆◆▲◆●□□ + \ 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 new file mode 100644 index 00000000000..b7bddd4156a --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_8.txt @@ -0,0 +1,17 @@ + + ◆◆□◆●□□●□▲◆ + ▲■□△◇◇◇□●● ◇ ■□ + ◆◇●▲◇△△□ ▲■◇△◇□○□○▲ + ◆□○□△◇▲□◇ ■ ●○◇◇○ + ■ △◇△▲○◇△○ △■○◇◇▲ + ◇■▲◇○○○◆○◇◆□ ○□◇◇◇ + ◇□◇◇◇ ■◆●○◇◆◇ ◇○◇△◇▲ + ○▲◇◇△ ◇□○△◇▲□△ △◆△◇◇ + ○ ○◇◇◆○ △□●◇◇△□▲□▲▲○◇◇◇ + ○□△●◆◇■▲◇▲◇◇●◇●◆◆◇◇●■ + △△○○◇○◇◇◇△■○□○□■○□■△○◇ + ■▲■◇○◇◆◆ △▲△△△■ + ■○◆□○○○◆ ◆△○◆◇□△□ + △△■◇▲□ ●●□□●△●▲◇◆ + ◇◇◆□□○◇◆▲□△●◆ + ◆◆ \ 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 new file mode 100644 index 00000000000..4342d3c81e5 --- /dev/null +++ b/codex-rs/tui2/frames/shapes/frame_9.txt @@ -0,0 +1,17 @@ + + △●○□△□□●▲ + △□◆△◇◇□■● ◆▲○◆ + △ ▲◇◇△□○○●△ ○◆◇▲ + ■ ◇◇△●●◆ ◆○▲◇○○◇□▲ + △□■●○□■◇□▲ □△■□△□◇◇ + ◇ ◇◇◇■○◇○●◆◆ ◇■◇□◇◇◇ + ◆ ◇□○◇□□◇○○▲■△▲△ ◆◇◇ + ◇ ◇△◆▲○◇◇◇◆△●○◆◇ ◇◇ + ■▲◇■ △◇■◇◇◇◇□◇◇▲◇◇◆ + ◇◆◇◇◇◆▲◇○◇◆◇◇◇◇◆◆◇◇◇ + ■ ◆○△○○◇△■□□□□◇◇■△○◇ + ○◇◇◇○▲◆ ▲△ ○△◇◆ + ○□○◇○○ △●◆●◇△□ + ○▲■○◆◇□△□●●●□◇◆ + ■▲◆ □○○◇●□△■ + \ 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 new file mode 100644 index 00000000000..514dc8ac49c --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_1.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..bd3b8fafff4 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_10.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..9eaf147a6a0 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_11.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..11163a99b9b --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_12.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..eb072e40ad2 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_13.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..100f3093023 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_14.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..5761f309d46 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_15.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f9001140ed8 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_16.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..696d932d409 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_17.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..abb0da53d29 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_18.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ffc4d2b4755 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_19.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f4419e3d693 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_2.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..0039bd880b1 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_20.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..87e3597d5d8 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_21.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..8dfe7daaab6 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_22.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f573acb7142 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_23.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..92833e8c589 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_24.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..d8b8655dacf --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_25.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..4be73d44de0 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_26.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f333909d2b7 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_27.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..3c0deb542c8 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_28.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..0c6277f4d52 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_29.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..b1e91736085 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_3.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..9dfd28bc20d --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_30.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..1dba8edd8f7 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_31.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..33160e71634 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_32.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..ff8827f3d2f --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_33.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..4b1eb6a5a23 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_34.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..f2432dc0adf --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_35.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..c84a104e4ac --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_36.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..2eed2c84653 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_4.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..e0c7693a9ec --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_5.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..d5ac091f39c --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_6.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..02d1f1ae521 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_7.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..d028ab360ee --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_8.txt @@ -0,0 +1,17 @@ + + 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 new file mode 100644 index 00000000000..2481e07a357 --- /dev/null +++ b/codex-rs/tui2/frames/slug/frame_9.txt @@ -0,0 +1,17 @@ + + -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 new file mode 100644 index 00000000000..0ca3a5d334c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_1.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▉▌▌▉▊▎ + ▎▌▊▋▉▍▉▋▉▍▌▏▏▌▎ ▎█▉▎ + ▊▏▉▏▉▉▉█▍▎ ▎█▉▎█▏▌▏▏▏▉ + ▌▉▎▍▉█▊▊▎ ▋▉▏▌▏▊ + ▍▍▌▋█▍▏▍▎▍▍ █▋▏▍▍▊ + ▏▉ ▉▎ ▏▉▋▌▏▏▊ █▋▍▏▏▊ + ▉▍█▊▉ █▍▏▉▋█▏▎ ▏▋▏ + ▏▏▎▏▎ ▊▋▋▏▌▏▉ █▎▏ + ▏▌▏█▎ ▌▏▏▍▍▏█▋▏▉▉▉▉▉▉▎▉▊ ▌█ ▏ + ▎▏▌▉ ▎▌▉▎ ▋▉ ▏█▏▎▎▎▋▋▊▊▊▏▋▊▋▊▏ + ▍▍▎█▍ ▍▍▊▋▋▎ ▎▍▉██▉ ▍▉█▋▊▌▎▋ + ▉▍▊ █▊ ▎ ▊█▋▉▎▏ + ▍▍▊▎▍▉▎ ▎▌▎▉▏▎▉█ + ▍▉▊▍▎ ▉▉▋▌▌▌▌▋▌▉▉▎▊▏▉ + ▎▉█▉▏▏▏▎▎▎▊▎▌▉▉█ + \ 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 new file mode 100644 index 00000000000..b422fb1274e --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_10.txt @@ -0,0 +1,17 @@ + + ▎▉▉▉▉▍▉▉▎ + ▉█▎▉▉▉▍▏▋▎▉▊ + ▍▎▊▏▏▋▏▏▊▏▉▍▊▊ + ▏▎▎▋▍▏▌▎▋▍▊██▍▍▊ + ▋ ▌▏▏█▏▍ █▊▋▎▋▏▋ + ▏▎ █▏▉▏▋▋▍ ▎▎█▏ + ▍ ▏▉█▉▏▏▏▏▉ ▏▋▊ + █ ▏▏▍▉▋▉▏▏▊▎▎▋▍▏▏ + █ ▏▍ ▍▍▏▌▏▏▉▍▉▌▏▏ + ▏ ▊▏▍▊▏▎▋▎▉▎▎▎▏▉▎ + ▊ █▏▏▏▏▏██ ▍▊█▍▏▎ + ▍▎█▊▍▊▉█ ▎▏██▊▋▋ + ▏█ ▏▏▏▏▉▊▋▊▋▏▋▎ + ▌▎▉▉▋▏▉▌▎ ▋▋█ + ▉▊ ▎▉▍▎▊▌▉ + \ 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 new file mode 100644 index 00000000000..5d4524e2938 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_11.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▉▉▎ + ▋█ ▌▏▏▏▍▍▊ + ▋█▎▎▏▏▌▍▉▋▍▊ + ▏▎▎▏▏▌▌▊▎▌▋▋ + ▎█▋▌▍▏▉▍█▌▎▏▏▊ + ▏▉▎▏▏▉▎▌▌▏▎▋▏▏ + ▉▉ ▊▏▏▏▏▏▏▎▏▌▍ + ▏ ▏▏▋▏▏▍▋ ▏█▏ + ▋ █▏▋▏▏▉▋▉▏▎▏ + ▉▉ ▎▏▋▌▊█▋▏▎▏▍ + █▎▊ ▏▏▏▏▌▋▏▍▍▏ + ▍▎▊▋▏▎▉▋▉▉▌▏▎ + ▎ ▉▏▍▍▍▎▏▌█ + ▍▉ ▉▏▏▊▎▋▉▎ + ▍▎ █▉▉▎▉▎ + \ 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 new file mode 100644 index 00000000000..f81900edb1c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_12.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▋ + ▊▌▎▎▋▏▊▍ + █ ▏▋▍▉▊ + ▋▉▏▏▏▌▌█▏ + ▏ █▏▏▏▏▋▋▏ + ▏█▋▋▉▍█▎▏█ + ▏ ▏▋▍▏▏▍ + ▏ ▋▉▍▏▏█ + ▉▊ ▏▋▏▏▉▏▏ + ▏ ▋▏▉▌▉▎ + ▏▋ ▏█▌▉▉▋ + ▊ ▉▏ ▎▊▏ + ▋ ▉▉▏▊▉▍▏ + █▍▋█▊▏▍▏▎ + ▍ █▏▍▋▋ + \ 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 new file mode 100644 index 00000000000..4231032a45c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_13.txt @@ -0,0 +1,17 @@ + + ▋▉▉▉▊ + ▏▎▎▏▏ + ▏▎▎▏█ + ▏▉▉▏▎ + ▉ ▏▏ + █▋▊▏▏ + ▊ ▏▏ + ▉ █▏ + ▋▉ ▎▏▋ + ▏█ ▉▌▏ + █▎▋▋ ▏▎ + ▏▎▎▎▏ + ▏▉▊▏▎ + ▉▎▎▏▌ + ▊ ▋█▌ + \ 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 new file mode 100644 index 00000000000..6eab794e0ab --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_14.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▌ + ▊▋▎▎▋▍ + ▏▎▏▏▋ ▏ + ▏▏▋▏█▍▏▊ + ▏▏▏▍ ▏ + ▏▉▏▏ █▏ + ▏▏▏▏ ▋ ▉ + ▏▏▍▏▎ ▍ + ▏▏▏▏ █ + █▍▋▋ ▌▋ + ▍▏██▊▋▊▏ + ▏▉▏▏▎▎▎▏ + ▋ █▏▌▌▌▎ + ▏▍▋▉▎▎▋ + ▎▉ █▌▋ + \ 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 new file mode 100644 index 00000000000..fa9a859bd04 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_15.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▏▎ + ▎▊▌▏▏▍▋▉▊ + ▋▋▏▉▌▍▏█▉▏ + ▏▋▎ ▋▋▊▋▎▎▏ + ▏▏▌█▌▍▏▏▋█▍▏ + ▍▏▏▊▏▏▋▋▏ █ + ▏▏ ▏▏▏▏▏▏▊▏ + ▏▏▋▏▏▉▏▏▏ █▊▏ + ▏▏▉▊▊▉▏▏▏▎▋▋▏ + ▏▎▏▌▋▏▍▏▉▋ ▋▏ + ▏▋▏▏▏▏▎▏ ▎ ▌ + ▏▉▉▋▍▏▏▍▊ ▌ + █▏▍▊ ▋▋▋▎ ▌▎ + ▏▍▏▏▍▍▋▉▍▋ + ▍▍▉▍▎▎▎▋ + ▎▎ \ 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 new file mode 100644 index 00000000000..1fcc2090a21 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_16.txt @@ -0,0 +1,17 @@ + + ▎▌▉█▉▉▉▏▎ + ▋▍▏▏▉▏▏▍▏▌▍ + ▏▋▋█▍▋▌▍▏▏ █▍ + ▋▋▋ ▌▉▎▍▏▍▏▏▉▉▏ + ▏▏▌▎▋▉█▌▏▏▉▋▏▎▍ + ▏▏▏▉▏█ ▏▍▌ ▏ ▏▋▏ + ▍▍▏ ▋▎▊▏▏▏▏▋▉█▊ ▏ + ▏▋▌ ▏▎▏▏▋▏▏▏█▌ ▎▏ + ▍▍▏▏▏▉▏▋▍▏▏█▉▉ ▋ + ▉█▎▎▊▌▌▍▉▏▋▎▏ ▊▎▏ + ▏▏▏▉██▋▉▍▏▌█▌▋▏▏▎ + ▏▏▉ ▏▎ ▎▋▋▊ ▋ + ▍▏▉ ▏ ▋▊▋▎▊▏ + ▍▏▏█▎▉▏▋▋▉▎▏ + █▋▋▎▌▋▎▎▌▉ + \ 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 new file mode 100644 index 00000000000..1adf01af903 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_17.txt @@ -0,0 +1,17 @@ + + ▊▌▉▉▉▌▌▏▊▎ + ▎▌▉▉▏▏▏▉▌▉▏▊█▍▊ + ▊▉▍▋▎▌▌▎▉▊█▏▏▏▎█▍ + ▋▏▋▎▋▏█▎▎ █▏▌▍▏▏▏▌▍ + ▏▋▎ ▏ ▏█▋▍▌▍▋▋ ▊ + ▎▌▉▊▋▎ ▊▋▋▏ ▋▊▌▋▏▋▋ + ▉ ▌▋▏ ▊▋▋▏▎ ▉█▏▏▋▌▏ + ▊█▌▏ ▋▋▏▏▋▏▊▏▏▌▌▉ + ▊▋▉▋▍▌▌▉▏▍▎▏▍▍▋▍▏▏▏ ▏ + ▏█▎▌▎▎▏▋▉▍▋▏▏▍ ▍▉█▉▍ + ▍▍▍▋███▉▉▉▉▍▎▏▉▋▋▍ ▍█ + ▍▏▏▎▉ ▎▋▏▏▎ ▌ + ▎▍▍ ▉▍▎ ▊█▊▋▍▎▋ + ▊ ▏▉▉▌▍▉▎▎▌▉▋▏█ + ▎▉▌▋▎▊▉▏▉▎▉▉ + ▎ \ 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 new file mode 100644 index 00000000000..9c46c648214 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_18.txt @@ -0,0 +1,17 @@ + + ▎▌▌▉▌▌▉▌▏▊▎ + ▎▉█▎▌▊▉▉▏▎▎▊█▉▌▌▎ + ▎▋▋▏▉█▉▌█▊█▋▎█▉▋▋▍ ▉ + ▊▏▏▉▋▊▍█▉ █▍▎▎▋▌▏▍▌▍▍▍▊ + ▎▍▋▋ ▋▏ ▎█▉▎▍▋▏▋▏▎▍▌▍▍▎ + ▋▍▏ ▊▊▏▋▎▎▎▋▊▉▊▏█▊▋▍▋▎▋▏ + ▏▏▍▋▋ ▉▋▉▋▏█▎▋█▉▏▌▏▌▊ + █▍▊▏▏ ▍▍█▏▏▌▍ ▏▌▏█ + ▍██ ▊▍▌▌▌▌▌▉▏▏▊▏▉▋▍▊ ▏▉▏▎ + ▎▍▍▏▉▍▉ ▏▏▋▎▎▍▎▏▏▏▎▌▏▎▍ ▊ + ▏▋▏▍▎██▉▉▉▉▉▎ ▍▎▎▋▏▋▉▋▎▊▎ + ▍▍ ▊▍▊ ▎▎▉█▋▉▎█ + ▍▍▉▉▍▍▋ ▎▉▍▎▋▍▊▎ + █▏▋▉▉▉▉▌▌▌▍█▉▎▋▎▊▉ + █▏▏▎▋▎▎▊▋▉█▌▉█ + ▎ \ 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 new file mode 100644 index 00000000000..572f5ffc324 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_19.txt @@ -0,0 +1,17 @@ + + ▎▎▌▌▉▏▉▌▌▏▋▎ + ▎▉▏▏▏▍▋▌▌▌▎▉▎▊▏▉▊▊ + ▊▉▌▌▋█▏▉▉▎ ▎▉▋▊▎▉▌▍ + ▋▌▏▉▊▍▎ ▎▌▌▉▊▍▏▉▊ + ▏▏▋▎▋▎ ▊▏▍█▋█▏▍ ▏▍▊ + ▏▍▋▉█ ▎▏▉▏▋ ▋▋ █▋▉▏ + ▍▌▏▏█ ▋▉▍▋▏▉▊▎ ▋▋▋▉▏ + ▏▏▏▏ ▏▌█▍▏█▊ ▏ ▏▏▏ + ▏▉▏▍▊▌▌▌▌▌▌▏▏▏▏▉▉▌▉▏▌ ▍ █▏▏ + ▍▌▋▊▏▌▉▋▍▉▉▍▊▎▊ ▍▎ ▋▍▎▍▊▋▋▏▎ + █▍█▋▉▏█████▉▉█ ▍▍▎▋▏▋▋ ▋▋█ + ▍▍▍▉▎▍ ▎▋▋▎▋▋█ + █ ▎▍▏▏▉▊ ▊▌▌▋▋▏▌▎ + ██▋▏▉▋▏▉▎▎▌▌▋▉█ ▌▉▏█ + █▏▋▎▋▊█▎▊▋▍▉▊▍ + \ 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 new file mode 100644 index 00000000000..0e0c021f436 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_2.txt @@ -0,0 +1,17 @@ + + ▎▋▎▋▌▉▌▌▌▉▊▎ + ▎▉▊▋▉▌▉▏▏▏▌▏▏▌▎█▏▉▉▎ + ▊▏▉▏▍▉▉▏▉▎▎ ▎█▉▌▉▋▏▏▌▏▊ + ▎▏▋▎▋▉█▊▊▎ ▊▊▍▏▋▏▊ + ▊▍█▋▉▍▏▍▎▍▍▎ ▍▊▏▍▏▊ + █▋▉█▎ ▏▉▍▉▋▍▉ ▍▍▏▉▏ + ▊▏█▉▏ ▍▋▏▌▏▎▏▊ ▋█▏█▏ + ▏█ ▍▎ ▊▏▉▏▏▌▉ ▏█▋▋▏ + ▉██▌▏ ▌▏▍▍▎▏█▋▏▉▉▉▉▉▉▉▉▊▏▎▏▏▉ + ▎▌█▏ ▋▏▏█ ▋▉ ▏▌▍▎▎▎▎▋▋▎▎▏▉▋ ▏ + ▍▍▍ ▉ ▉▍▋▋▏█ ▎█▉▉▉▉▉▉▉█▏▊▉▏▏ + █▍▎▋▋▊ ▎ ▊▉▌▋▊▉ + ▋▍▎▎▏▉▊ ▊▌▎▉ ▎▏█ + ▎▏▍▌▎▎█▉▉▋▌▌▌▌▋▌▉▎▎▏▏▉ + ▎▉▉▉▉▏▏▎▎▎▎▏▌▋▉█ + \ 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 new file mode 100644 index 00000000000..42c288df929 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_20.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▏▏▌▉▌▊▎▎ + ▊▌▌█▌▊▎▊▌▌▌▏▏▎▍▉▍▉▊ + ▊▊█▎▉▉█▏▉█ ▎█▍█▍▉▉▏▉▎ + ▎▏█▎▉▏▉ ▎▊▉▏▍▋▉▍▍ + ▎▏ ▊▏▉ ▎▋ ▎▏▏▍▉▍▏▏▍ + ▋▎▊▏█ ▉▉█▌▏█▋ █▍▏▏▍ + ▌▏▌▉▏ ▊▉ ▋▉▉▍ ▊▍▏▋ + ▏▎▍▋▎ ██▌▋▏█▊ ▏▏█▏ + ▏▋▏▏▌▊▌▌▌▌▌▌▌▌▎▊█▌▍▍▍▍▉ ▋ ▏▍ + █▏▍▏▎█ ▋▎▎▎▎▎▎▏▍ ▉▍▍█▏▌▍ ▊▋ ▏▏ + ▍▊▍▍████████▉█ ▍▊▌▏▏▊▏ ▋▍ + ▉ ▍▉▋▊ ▎▋▉▋▏▋ + ▍▎▍▉▊▏▎ ▊▌▉▎▌▉█ + ▍▊▊▋█▏▉▍▌▌▊▋█▏█▎▊▋▉█ + ▎▉▉▏▎ ▌▎▎▎▎▏▉██ + \ 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 new file mode 100644 index 00000000000..aa5d4f7274c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_21.txt @@ -0,0 +1,17 @@ + + ▎▎▊▌▉▌▌▉▌▉▊▎▎ + ▊▉▉▎▎▎▎▉▏▌▏▉▌▎▎▊▉▉▎ + ▌▉▍▎▉▉▍█▎ ▎▍▏▉▉▎▎▉▊ + ▊▉▏▌██ ▎▊▌▉▊▌▉▊ + ▋▊▋▉▎ ▊▏▋▎▍ ▍▍▎▍▊ + ▋█▋▏ ▊▉▉▎▋▋ ▍▍▍▊ + ▏█▏▎ ▎ ▋▎▊▋█ ▍▌▍█ + ▎▍ ▍▌ █▏▊ ▏ ▏ + ▏ ▏ ▊▉▌▌▌▌▌▉▉▉ ▍▏▊█▏▊ ▌▋ ▏ + ▏ ▏▍ ▏▎ ▎▊▏ ▍█▎▉▏▊ ▋█▋▋ + ▌ █▋ ▎▎███ █▎ █▉▎▊▌ ▋▎ ▊ + █▉ ▋▌▎ ▎▉█ ▋ + ▉▎ ▍▊▎ ▎▋▌▉█ ▊▎ + ▉▌▎ ▉█▉▉▋▏▏▌▋▌▉██ ▊▎ + ▉▉▉▌▉▎▎▎▎▊▎▌▉▉▎ + \ 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 new file mode 100644 index 00000000000..3b1ce4ecded --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_22.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▌▉▌▉▊▎ + ▎▉▋▉▏▋▎▋▏▋▍▏▏▌▏▉▋▉▉▎ + ▊▌▉▌▏▍▉▉▎ ▎█▉▍▉▉▉▎▎▉▊ + ▌▉▏▉▉█ ▊▊▏▏▏▎▉▍ + ▍▏▏▋█ ▊▎▋▏▌▏▋▋▌█▏ + ▍▏▉▋▎ ▊▏▋▏▌▍▋▎ ▍▏▍▌▏ + ▏▉▉▍▋ ▏▉▏▍▉▋█ ▏▏▋▏ + ▏▏▌▏▎ ▏▍▏▏▍▍ ▏▍▏█▏ + ▏▏▊█▊ ▉▏▌▌▌▌▌▌▉▉▎▍▉▏▎ ▍▊ ▎▏▌█ + ▍▏▌ ▉▎▍▍▊ ▋▊█▏▋▋▏ ▍▉▌▎█▏▊▊ ▋▍▌ + ▍▍▍▎▏ █████▉▉█▎ ▎▋▉▉▎▋▋▋█▊▎ + ▍▉▍▎▍▊ ▎▉▊█▏▊█ + ██▉▌ ▉▉▎ ▎▋▉▊█▉▎▋ + █▋▉▉▊█▉▏▌▌▉▌▌▋▌▉███▊▉ + █▏█▏▎▌▎▎▎▊▌▎▋▌▉▎ + \ 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 new file mode 100644 index 00000000000..0b99396129d --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_23.txt @@ -0,0 +1,17 @@ + + ▎▊▉▎▌▉▉▉▉▌▉▎▎ + ▎▌▏▉▏▍▎▊▎▌▌▊▏▎▏▉▏▉▎ + ▊█▉▏█▊▌▉▎ █▏▋▉▏▍▌▏▏▊ + ▋▌▏▉▌▋▎ ▎▌▌▍▏▏▌▍ + ▋▋▏▎▋█ ▊▋▊▊█▉▋▉▍▏▍ + ▊▍▏█▋▎ ▎▉▉▉██▋▉█▏▏▍▏▊ + ▎▏▏█▏ ▋▋▋▋ ▎▏▎ █ ▏█▉ + ▏▋▏ ▏ ▏█▏▊▎▍▊ ▏ ▉▋▏ + ▍█▍ ▏▉▏▌▌▌▌▌▌▌▌▏▎▉▏▎█▍▎ ▏ ▏▏▏ + ▏▍▍▎▏█▌█ ▋▎▍ ▍▏▎▍▊▏ ▍▎▏ + ▋█▏ ▏ ▎███████ █▎▋▌▌▏▎▋▍▋▎ + ▊▏▏▎▏▋ ▊▋▏▍▍▋▎ + ▍ ▏▊█▋▎▎ ▎▋█▌▉▋▉▋ + ▍▌▍▉▌▉▉▍▏▌▌▎▉▉█▊▎▉▉▎ + █▏ ▉▌▍▏▎▎▉▎▎▉▉▎ + \ 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 new file mode 100644 index 00000000000..5e26d7a27bf --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_24.txt @@ -0,0 +1,17 @@ + + ▎▏▎▌▉▉▉▌▌▊▎ + ▊█▎▋█▏▎▎▏▌▌▉▊▎█▉ + ▊██▌▍▋▉█▏ ▋▎▋▉▏▉▉▊ ▍ + ▊█▋▋▊▋▏▌▊▍▎▏▋▎ ▎▉▍▏▍▎█ + ▊ ▋▋▍▋▊ ▎██▋▍▎▏▎▎▍▏▊█ + ▏▊▍▉▍▎ ▉▊▊▋ ▋▏▌ ▋█▍ + ▏ ▏▏ ▊▋▎▊▋█▋▍▍▏▍▍▏▍ + ▏ ▍▏▏ ▏▊▌▋▍▎▏▏▎▋▋▎▏▏ + ▏▋ ▏▌▌▌▋▉▉▏▏▏▉▉▎▉▊▎▏ ▏ ▏▏ + ▎ ▋ ▉▏▋▊▊▌▌▊▊▏▋▍▍▉▉▏ ▏▉▏ + ▍ █▍▏▍▉██████▎ ▍▊▋▉▍▏▌▋▊▎ + ▍ ▍▊▉▍ ▏ █▋▌▋ + ▍▋▉▍▎▏▋ ▎▌█▎▋▋▎▉ + ▋▊▉▊▍▍█▏▏▋▌▌▌▏▍▎▋▎ + ▉▋▎▉▉▌▍▎▎▌▌▉▉ + \ 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 new file mode 100644 index 00000000000..5009b8b66d2 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_25.txt @@ -0,0 +1,17 @@ + + ▎▉▌▉▌▉▉▉▉▎ + ▊ ▉▊▉▎▎▌▍▏▉▏▏▊ + ▋▏▋▋▊▊█▋▏▉▍▌▍▏▏▏▎ + ▏▉▉▏ ▋ ▌▏ █▍▏▊▉▍▋ + ▊▎▎▍▊▋█▍▌▋▎▏█ ▏▋▋▍▋▉ + ▍▊▏▏▏▎▉▉▋▍▏▋ ▋▏▊▏▋▋▏▊ + ▏▋▏▉▏ ▏▎▎▋▍▎▋▏▍▋▎█▏▌▏ + ▏▏▏▋▏▋█ ▏▎▌▍▋▏▍▎▏▌▏▏▍ + ▍▉▍▏█▏▎▎▌▌▌▌▉▏▏▍▍▏▉▉▏▍ + ▋▊█▏▋▏▊▏▎▏▏▌▉▎▏▍▉▌▋▍▏ + ▍▏▉▋▍▏▎██████▉▋▏▍▏▋▏▎ + ▏▎█▏▌▉▌▍▊▊▉▋▎▋▊▋▋▏▋ + ▍▎▍▏▎▍▎▉▌▋▊▍▋▎▏▏▋ + ▍▋▌▍▍▎▉▌▏▉▉ ▉▏▉ + ▏▊█▉▍▉▊▎▉▏▉ + \ 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 new file mode 100644 index 00000000000..900a51c3b55 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_26.txt @@ -0,0 +1,17 @@ + + ▎▌▉▉▉█▉▊ + ▋▉▏▌▋▍▏▉▌▏▊ + ▋▉██▏▌▍▏▍██▍▊ + ▌▉█▊▏▏▏▎▏▏▎▉▋▍ + ▏▎▊▍▏▋▏█▉▍▋▌▏▍▌ + ▍ █▏▋▋▋█▉▎▍█▍▍▋ + ▏ ▉█▌▏▏▋▏▏▌▌█▉ + ▋▊ ▍▏▏▏▍▍▌▏▏▋▏▏ + ▏ ▋▍▉▌▉▏▉▌▌▋▍▏▏ + ▋▎▏▋▊▏▎▎▊▉▍▍▉▏ + ▏▎█▋▏▉█▍▊▋▎▉▍▏▏ + ▍▎▏▏▋▍ ▋▋█▏▏▎ + ▉ ▉▏▍ ▍▎▋▏▋▊ + ▏█▉▍▍█▉▎▉▉▌ + ▍▋▋▍▉▏▎▍▉ + \ 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 new file mode 100644 index 00000000000..0b2e8c7306f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_27.txt @@ -0,0 +1,17 @@ + + ▌▉▉▉▉▊ + ▏▌▋▋▏▋▍ + ▌█▎ ▉▏▋█▏ + ▏▏▏▍▏▏▌▏▏ + ▏ ▎▏▎▊█▌ + ▏▎▋▎█▎▏▏▏▎ + ▌ █▏▏▎▉▏▏ + ▏ ▎▏▏▏▌█▏▏ + ▏▋ ▋ ▏▉▏▏▏▏ + █▌ ▋█▎▏▎▉▏▏ + ▏▊ ▎▍▉▉▉▋█ + ▏▏▉▉▏▏▎▋▏ + ▏▌▌▉▌▊▋▏▍ + ▍▎▎▏▍▌▋▋ + █▍█ ▍▍▋ + \ 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 new file mode 100644 index 00000000000..01ce82b6d3c --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_28.txt @@ -0,0 +1,17 @@ + + ▋▉▉▌ + ▏▉▎▏▎ + ▌ ▌▏▎ + ▏▊▎▉▏ + ▏ ▏▏ + ▏ ▊█▏ + ▍▏▏ ▎▏ + ▏▊██▏ + ▋▍ ▎▏ + ▏▏▋▋▏▏ + ▏ ▎▏ + ▏▌▉▌▏ + ▏▌▉▏▏ + ▉▎▎▋▏ + ▏ ▋▉▏ + \ 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 new file mode 100644 index 00000000000..c682a6082c1 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_29.txt @@ -0,0 +1,17 @@ + + ▉▉▉▉▉▊ + ▊▏▉▋ ▉▍ + ▍▊▏▏ ██▏ + ▏▏▏▏▍▍▊▋ + ▍ ▏█▏ ▏ + ▏▊▏▏▏ ▎▎ + ▏▍▍▏▏▏ ▍ + ▏▏▌▏▏ + ▏▏▏▉▏ ▎ + ▎▏▌▏▏ ▎ + ▉▏▏▋▏▍ ▌ + ▏▋▏▏▏ ▎▏ + ▏▏▏▏▎▏▎▏ + ▏▌▏▉▋█▏█ + ▏▏▉ ▋▊ + \ 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 new file mode 100644 index 00000000000..6c202bc0c38 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_3.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▉▌▌▌▌▊▎ + ▎▉▊▋▍▏▏▉▏█▌▌▏▌▎▍▏▉▉▎ + ▊▌▋▉▌▏▉██▎ ▎█▏ ▉▉▉▋▍▏▊ + ▋▉▋▍▏▌▊▉▎ ▎▌▉ ▏▏▍ + █▋▉▏▋▍▋▏▏▉▍▎ ▍▍▍▏▏▎ + ▋▏▍▍▋ ▋▊▏▍▌▍▍ ▏ ▍▍▏ + ▌▉ ▏ █▉▍▏▎█▏▊ ▋▉▋▏▊ + ▊▊▏▊▏ ▊▊▏█▋▍▏ ▏▎▏▌▏ + ▊██▏ ▌▏▉▉▏▏▉▊▏▌▉▉▎▎▎▉▉▎▏▍█▌▏ + ▊▍▋▍▊ ▋▋▉▋▌▌▏ ▏▉▏▎▎▊ ▌▋▊▌▍▎▏▍▏▎ + █▍▏ ▏ █▏▏▉▊█ █▉▉▉███▉▉▊▋▋▋▊▋ + █▉▏ ▍▉ ▎ ▋▍▉▍▋▋ + ▌▍▋▋▉▍▊ ▊▉▌▋█▊▏█ + ▌▍▉▊▎▉▉▍▌▌▌▌▊▋▌▉▌▎▎▉█ + ▎▉▉▉▎▋▏▎▎▎▎▏▌▋▍█ + \ 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 new file mode 100644 index 00000000000..a44dbb6ed04 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_30.txt @@ -0,0 +1,17 @@ + + ▎▌█▉▉▉▊ + ▎▋▌▏▏▍▉█▌▍ + ██▍▏▊▍▍▏▉▊▍ + ▏▏▍▏▋▏▎▍▏▍ ▍▊ + ▌▉▏▏▎▍▏▊▏▊ ▏ + ▏▉▏▍▉ ▉▏▏▏▏▋▏ + ▍▋▏▏▏▍▎ ▍▋▍▏ ▌▎ + █▏ █▉▌▏▊▏█▏▊ ▎ + █▏▎█▍▏▌▏▍▋▏▊ ▎ + █▏▏▍▏▎▎▏▏ ▉▊ ▎ + ▍▏▏▌▍▎▉▏▏▏▉▏▏ + ▏▉▋▋▊▋▉▍▏▏█ ▏ + ▍▏▍▏▏▋▉▏▏▊▏▋ + ▊▍▊▉▌▍▋▏▊▋█ + ▍▊▋▏▍▎▎▌█ + ▎▎▎▎ \ 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 new file mode 100644 index 00000000000..70da8799e29 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_31.txt @@ -0,0 +1,17 @@ + + ▎▋▋▉▉▉▉▌▊ + ▎▉▉▌▉▊▎█▉▍█▉▊ + ▊▏▍▋▋▊▌▎▎▋█▏▎▉▍ + ▊▏▋▉▎▎▉▌▎▊▍▍▏▍▏▏▍ + ▍▏▏▏▏▉▊▉▍▎▊█▍▏▋▎▎ + ▌▏▍▎▏▎▏██▍▌▉▊▋▋▏▊█▏ + ▋▏▏ ▏▍▉▏▍▋▌▋▌▉▏▉▏▋▏ + ▍▍▊ ▏▏█▏▍ ▏▍██▏▏▍▋▏ + ▏▍▊▎▏█▍▏▏▏▋▉▏▏▏▎ ▏ + █▏▏▏▋▋▏▏▎▎▎▍▎▏▍▏▏ ▉ + ▍▉▎▋▉▏▊ ▋▉▉▉▎▋▏█▏▎ + ▏▏▏▍▍▍▊ ▎▌▉ ▏ + ▌▏▉▌▎ ▊ ▋ ▏▉▎▋▎ + ▋▏▍▏▋▎▏▎▊▏█▊▏▎ + ██▌▏▎▉▉▋▋▌▉ + ▎▎▎▎ \ 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 new file mode 100644 index 00000000000..ddfb4be3fe2 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_32.txt @@ -0,0 +1,17 @@ + + ▊▉▉▉▉▌▌▏▊▎ + ▌▉▏▉▉▏▍▏▏▉▉▏▏▊ + ▋▍▉▌▍▊▉▍▋▊▎▉▏▉▍█▉▊ + █▋▋▏▎▊▎▌▊ ▊ ▊▎▍▏▉▊ ▊ + ▋▋▏▊▏▉▍█▉▏▌ █▎▊▏▍▋▍▉▍▊ + ▍▋▍▍▊▍▉▏▎▋▏▏▉▎ ▏▉▋ ▉ + ▉▏█▌▊▎█▍▏▏▊▊▏▊▌▎▋▎▋▋▏█▎ + ▏▏▏▊▎ █▎▏▏█▋█▏▌▊▎█▍▏██▎ + ▍▏▏▊▎ █▎▏█▎▎▏▉▉▉▏▏▏▌▏█▌▎ + ███▎▎▏▉▍█▍▎▎▎▎▎▏▏▉▏▌▏▊▎ + ▊▍▉▏▏▋ ▏▉██▉▉▉▍▌▋▋▋▋▋ + ▍▊▏▍▍▍ ▊▍▏▋█▏ + █▉▉▏▏▏▉▎ ▎▋▎▏▋ ▊ + ▍▊▍█▏▉▏▌▌▉▎▎▉█▎▋ + ▏▌▌▎▎▊▉▋▉▎██ + ▎▎▎ \ 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 new file mode 100644 index 00000000000..7fa5ac29bca --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_33.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▉▌▉▌▏▋▎ + ▎▉▉▏▏▍▉▌▏▋▏▊▏▉▏▉▋ + ▎▋▍▏▉▉▉▊▌█▎ █▉▋▏▏▉█▊ + ▊▍▋▏▋▎▏▊▎ ▉▏▏▍▎▉ + ▎▋▏▋▎▏▏▋▍▍▍ ▏▉▍▍▍ + ▋▋▊ ▍▎▍▍▎▍▊ ▋▍▋▎▍ + ▏▏▉ ▍▊▉▏▎▏▍ ▏▉▏▏ + ▉▏▊▊ ▊██▍▋▌▊ ▍█▏▉ + ▏▏▏ ▏ ▊▎ ▋▉▊▊▊▌▌▌▉▏▏▉▋▎▏▍▏ + ▍▋▏██▋▋▊▋▎▏▏▎▎▎▎▎▏▏▉▍▍▍▏▏▋ + ▍▌▍▊ ▏▋▏▋ ▋ ████▉▉▉ ▏▏▊▋▍▎ + ▍▉▍▉▊█▊ ▋▋▌▋▌▋ + ▉▍█▉▊▉▌▊ ▎▉▏▊▋▉▍█ + ▍▏█▉▍▉▏▊▌▉▎█▌▊▍▉▏▉ + █▌▌▉▊▎▎▎▍▉▉▌▊█ + ▎▎▎▎ \ 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 new file mode 100644 index 00000000000..a8c447ff18a --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_34.txt @@ -0,0 +1,17 @@ + + ▎▊▌▉▉▉▉▌▏▊▎ + ▎▉▉▉▋▍▌▏█▏▉▉▌▉▉▌▊▌▎ + ▎▋▋▊▏▉█▋▏▉▎ ▎▍▍▍▊█▉▉▊ + ▊▋▎▏▉▎▋▊▎ ▉▏▊▉▍▍ + ▋▋▏▍▊▋▏ ▏█ ▊ ▍▍▉▍▍ + ▊▋▍▏▎▋ ▍▍█▏▍▉▍ ▍▊▍▏▍ + ▏▉▍▏▎ █▍▎▉▏▎▍ ▏▌▏▎ + ▍▏▌█▋ ▏▉▎▏▋█▏ ▋ ▎▍ + ▊█▋▋█ ▋▍▋▋█▎▌▌▉▉▉▉▉▉▏▎▎█ ▏▍▎ + ▏▏▍▊▋▊▊▍█▋▋█▏▏▏▎▏▋▎▎▎▊▋▎▏▏▎▏▍ + ▋▎▍▊ ▏▍▏▉▍ █▉▉▎▎▎▎▍ ▊▋▊▍▏█ + ▋▊▍▌▌▍▎ ▋▌▋▏▋█ + ▍▉▎▏▉▍▌▊ ▎▉▌█▎▋▊▎ + ▊▍▋▏█▏▉▊▌▊▉▌▉▌▉▎▉▉▋█ + █▏▍▌▌▎▎▎▎▎▌▌▉▌▍ + ▎▎▎ \ 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 new file mode 100644 index 00000000000..ba905231e1f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_35.txt @@ -0,0 +1,17 @@ + + ▎▊▉▌▌▉▉▉▌▉▊▎ + ▊▉▌▉▋▏▉▉▉█▏▉▏▋█▏▉▌▎ + ▎▋▋▏▌▉▉▏▍▉█▎ ▎█▉▌▉▋█▏▉▊ + ▊▋▋▉▋▊▌▊▎ ▉▉▎▍▏▎ + ▋▋▏▏▋▋▍ █▍▉▊ ▏▍▉▏▊ + ▌▋▌▋▉▎ █▏▎█▋▋▉▎ ▏▍▍▏ + ▍ ▋▋ ▍▍ █▉▎▋ ▏▍▍▍ + ▏▏▏▋ ▋▍▍▌▋█ ▋ █▋ + ▋ ▊▎ ▊▏▏▋▌█▋▏▌▌▎▌▎▌▉▏▏ ▏▏▌▉ + ▏▊▍▌█ ▋▋▌█▉▉▋▏▌ ▍▊▎▎▎▎▋█▏▋█ ▏█ + ▋▎▍▊█▋▍▎▏▋▍▎ ▎█▍▍▍▍▉▍▍▉▋▋▎▊▏ + █▋▍▍▋▏ ▎▋▉ ▊▍ + ▍▌▎▍▌▊▋▊ ▊▌▋▎▉▉█ + █▍▎█▏▉▏▊▋▏▏▉▏▌▋▉█▎▋▌█ + █▍▏▌▌▎▎▋▎▎▉▉▉█▍ + \ 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 new file mode 100644 index 00000000000..246ed3d6924 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_36.txt @@ -0,0 +1,17 @@ + + ▎▎▉▌▉▉▉▉▌▉▊▎ + ▎▌██▍▉▋▌▋▉▍▉▌▉▉█▉▉▉▎ + ▊▍█▍▊▉▉▊▉█▎▎ ▎█▉▏▉▉▏▊▉▏▊ + ▎▏█▉▉▎▎▎▎ █▉▏▍█▏ + ▊▋▎▌▍█▏▍▎▍▊▋ ▍▍▋▉ + ▋ ▊▋▎ ▉▎ █▏▋▊ █▍▍▉ + ▏ ▌ ▎ ▍ █▍▍ ▏▍▏▊ + ▏▏▍▏ ▎▍ ▎▋▋ ▍▏ ▏ + ▏██▏ ▋▋ ▊▉██▊▉▉▎▉▉▉▉▉▎ ▏▍▏▏ + ▎▉▏▍ ▊▏▎▎▋▏▋ ▎▏▎▎▎▎▎▎▎▏▏ ▍▋█▎ + █ ▉▏▍ ▉▎▉▋▍ █▉▉▍▍▍▍▍█ ▏▊█▋ + █▊█▍▌▋ ▊▋▏█▋ + ▍▋▏▏▉▏▊ ▊▉▉█▋▊▎ + ▉▊▎█▉▉▌▍▌▏▎▉▏▌█▊█▊▌▉█ + ▎▉▉▋▏▎▊▊▎▊▊▉▉▉▉█ + \ 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 new file mode 100644 index 00000000000..5dcae750bc0 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_4.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▉▌▌▉▌▉▊▎ + ▎▊█▉▉▏▋ ▏▍▌▋▉▌▏█▋▉▊▎ + ▊▉▌▏▏▏▉▉█▎ ▎▍▏▍▍▉▏▋▍▍▎ + ▋▋▏▊▏█▋▉▊ █▏▉▉▍▉▍ + ▋▏▋▎▏▌▏▍▌▍▏▊ ▍█▍▏▉▍ + ▋▏▏▍▏ █▎▏▏▋▉▍ █ ▍▌▏▊ + █▍▏▌▎ █▉▉▍▉█▏▊ ▊ █▌▏ + ▏▋▍ ▊▏▋▉▍▏▏ ▏▏▏ + ▎▏ ▏▎ ▌▏▋█▍▋▋▉▏▉▉▉▎▎▎▎▊▊ ▏▏▉ + ▏▍▋ ▏ ▊▋▋▋▊▎▏▎▌▍▏▊▋▎▊▋▋▍▌▏▋▋█▏ + ▎▏▌█▍▊▍▍▊▍▋ █▉▉▉▉▉▉▉█▍▊▏▏▏▎ + ▊▏▍█▏▎▎ ▊▍▌▋▉▉█ + ▍▍▊▋▍ ▊▎ ▎▋▍▊▉▌▏▋ + ▏▏▏▌▎▉▉▍▌▌▌▌▊▉▌▉▉▍▏▉▎ + ▉▍▍▏▋▏▎▎▎▎▎▌▊▉▎ + \ 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 new file mode 100644 index 00000000000..cab16091cb9 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_5.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▌▉▊▎ + ▎▉█▉▋▉▋▏▏▉▏▌▉▌▎▏█▉▊ + ▉▎▋▍▉▏▉█▎ █▍▌▏▉▍ ▊▉▊ + ▊▏▋▏▋▏▋▌▊ ▊▍▏▏▏▏▎ + ▊▍▋▉▋▍▎▉▏▎▍▊ ▎ ▍▏▍▏▎ + ▏▏▏▏▋ ▍▎▏▍▌▋▏▎ ▉▋▏▏ + ▏▋▏ ▉ ▎▍▎▏▍█▏▊ ██▏▉▏▊ + ▏▋▏▎█ ▊▎▌▏▋▎▏ ▉▏▎▏ + ▏▊▏▍▉ ▋▎▏█▊▏▉▌▎▏▉▏▎▎▎▉▊▎█▏▋▏ + ▎▍▏▍▊▊▋▉▉█ ▏█ ▋▋▍▌▍▊▌▋▊▋▏▋▉▏▎ + ▍▊▏▏▊▏▊▍▍▋▉▎ █▉▉█████▊▏▌▏▍▋ + ▍▍▉▋▍▍ ▎ ▋▎▋▉▊▍ + ▍▋▉▍▏▌▉▎ ▎▌▉▊▉█▌▉ + ▍▏▉▏▊▉▉▍▌▏▌▎▊▉▌▉▍▏▌▉ + ▍▊▉▎▉█▎▎▎▊▎▊▉▉▎ + \ 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 new file mode 100644 index 00000000000..e41e013ab0f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_6.txt @@ -0,0 +1,17 @@ + + ▎▋▌▌▌▉▌▌▉▉▎▎ + ▊▍▌▋▉▏▌▉▉▏▉▉▌▎▊▉▉▎ + ▊▊▊▏▏▏▉█ ▎█▏▋▌▉▉▉▋▍▎ + ▋▊▏▉▏▉▎▌▊ ▍▉▏▏▏▏▊ + █▌▏▏▏▉▍▍▉▉▍ ▋▍▍▏▍▊ + ▌▊▋▏▋▎▍▉▌▏▍▎▏▊ ▋▌▍▋▏ + ▎ ▉▏▏ ▍▍▏▋▍▍ ▍▉▏▎▏ + ▏ ▏▏▏ ▎▏▌▏▋▏▏ ▏▉▍▏▏ + ▎▋▏▊▏ ▋█▏▏▉▌▋▉▎▏▉▏▎▎▉▉▎▋▍▋▏ + ▏▏▍▊▋▎▍▎▌▉ ▏▋▌▉▊▏▏▊▌▌▌▍▏▊▏▍ + ▋ ▏ ▋▏▏▏▌▋█ █▉████▉█▎▍▍▋▏ + ▊ ▌ ▋▊▎ ▊▋▎▋▋▏▎ + ▋▎▍▎▉▉▊ ▎▉▍▎▋▉▊▉ + ▎█▏▉▊█▉ ▌▏▎▎▊▉▉▉ ▋▌█ + ▍▊▌▉▉▊▎▎▎▎▎▊▉▉ + \ 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 new file mode 100644 index 00000000000..7a88d5ef148 --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_7.txt @@ -0,0 +1,17 @@ + + ▎▋▏▌▉▉▌▌▉▊▎ + ▊▉▎▉▍▏▉▉▉▌▌▍▌ ▎▉▊ + ▉ ▊▏▏▏▉▎ ▎▍▏▎ ▉▋▎▉▊ + ▍▍▏▏▏▏▌▉ ▍▏▍▉▏▋▊ + ▎█▏▉▏▏▍▏▋▍▊ ▋ ▉▏▏▍ + ▏ ▌▏▏█ ▎█▍▌▏▍ ▊▊▏▏▏ + ▎▍█▏ ▍▎▏▍▏▍▏ ▉▉▍▏▏ + ▎█▋▏▏ ▌▉▋▏▉▏▎ █ ▏▏▏ + ▋█▏▉ ▊█▎▋▏▍▋▏▏▌▌▎▏▏▌▌▉▍▏▏ + ▏▊█▏▏▌▋▏▏▋█▏▋▍▏▏▍▍▊▌▊▌▍▏▍▏ + ▍ ▌▍▋▉▏▉▎▋ ▉▉▉▉▉▉██▎▋▏▏ + █▎ ▋▎▋▎▎ ▊█▊▋▌▋█ + █▌▊▉▍▍▍▎ ▊▍▏▊▉▋▉█ + ▍▎█▍▏▉▍▌▏▎▎▎▉▉▏▉▉▋ + █▏▉█▏▋▏▎▎▊▎▌▉▉ + \ 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 new file mode 100644 index 00000000000..bbf2016faba --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_8.txt @@ -0,0 +1,17 @@ + + ▎▎▉▎▌▉▉▌▉▊▎ + ▊█▉▋▏▏▏▉▌▌ ▏ █▉ + ▎▏▌▊▏▋▋▉ ▊█▏▋▏▉▍▉▍▊ + ▎▉▍▉▋▏▊▉▏ █ ▌▍▏▏▍ + █ ▋▏▋▊▍▏▋▍ ▋█▍▏▏▊ + ▏█▊▏▍▍▍▎▍▏▎▉ ▍▉▏▏▏ + ▏▉▏▏▏ █▎▌▍▏▎▏ ▏▍▏▋▏▊ + ▍▊▏▏▋ ▏▉▍▋▏▊▉▋ ▋▎▋▏▏ + ▍ ▍▏▏▎▍ ▋▉▌▏▏▋▉▊▉▊▊▍▏▏▏ + ▍▉▋▌▎▏█▊▏▊▏▏▌▏▌▎▎▏▏▌█ + ▋▋▍▍▏▍▏▏▏▋█▍▉▍▉█▍▉█▋▍▏ + █▊█▏▍▏▎▎ ▋▊▋▋▋█ + █▍▎▉▍▍▍▎ ▎▋▍▎▏▉▋▉ + ▋▋█▏▊▉ ▌▌▉▉▌▋▌▊▏▎ + ▏▏▎▉▉▍▏▎▊▉▋▌▎ + ▎▎ \ 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 new file mode 100644 index 00000000000..4e36e6e126f --- /dev/null +++ b/codex-rs/tui2/frames/vbars/frame_9.txt @@ -0,0 +1,17 @@ + + ▋▌▍▉▋▉▉▌▊ + ▋▉▎▋▏▏▉█▌ ▎▊▍▎ + ▋ ▊▏▏▋▉▍▍▌▋ ▍▎▏▊ + █ ▏▏▋▌▌▎ ▎▍▊▏▍▍▏▉▊ + ▋▉█▌▍▉█▏▉▊ ▉▋█▉▋▉▏▏ + ▏ ▏▏▏█▍▏▍▌▎▎ ▏█▏▉▏▏▏ + ▎ ▏▉▍▏▉▉▏▍▍▊█▋▊▋ ▎▏▏ + ▏ ▏▋▎▊▍▏▏▏▎▋▌▍▎▏ ▏▏ + █▊▏█ ▋▏█▏▏▏▏▉▏▏▊▏▏▎ + ▏▎▏▏▏▎▊▏▍▏▎▏▏▏▏▎▎▏▏▏ + █ ▎▍▋▍▍▏▋█▉▉▉▉▏▏█▋▍▏ + ▍▏▏▏▍▊▎ ▊▋ ▍▋▏▎ + ▍▉▍▏▍▍ ▋▌▎▌▏▋▉ + ▍▊█▍▎▏▉▋▉▌▌▌▉▏▎ + █▊▎ ▉▍▍▏▌▉▋█ + \ 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 new file mode 100644 index 00000000000..b8fd3886b3e --- /dev/null +++ b/codex-rs/tui2/prompt_for_init_command.md @@ -0,0 +1,40 @@ +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 new file mode 100644 index 00000000000..cc43f3294b4 --- /dev/null +++ b/codex-rs/tui2/src/additional_dirs.rs @@ -0,0 +1,71 @@ +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 => 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::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 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 new file mode 100644 index 00000000000..4d4970b5722 --- /dev/null +++ b/codex-rs/tui2/src/app.rs @@ -0,0 +1,1510 @@ +use crate::app_backtrack::BacktrackState; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ApprovalRequest; +use crate::chatwidget::ChatWidget; +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::model_migration::ModelMigrationOutcome; +use crate::model_migration::migration_copy_for_config; +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::ResumeSelection; +use crate::skill_error_prompt::SkillErrorPromptOutcome; +use crate::skill_error_prompt::run_skill_error_prompt; +use crate::tui; +use crate::tui::TuiEvent; +use crate::update_action::UpdateAction; +use codex_ansi_escape::ansi_escape_line; +use codex_app_server_protocol::AuthMode; +use codex_core::AuthManager; +use codex_core::ConversationManager; +use codex_core::config::Config; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::features::Feature; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::models_manager::ModelsManager; +use codex_core::protocol::EventMsg; +use codex_core::protocol::FinalOutput; +use codex_core::protocol::Op; +use codex_core::protocol::SessionSource; +use codex_core::protocol::TokenUsage; +use codex_core::skills::load_skills; +use codex_core::skills::model::SkillMetadata; +use codex_protocol::ConversationId; +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 ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +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; + +const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; +const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; + +#[derive(Debug, Clone)] +pub struct AppExitInfo { + pub token_usage: TokenUsage, + pub conversation_id: Option, + pub update_action: Option, +} + +impl From for codex_tui::AppExitInfo { + fn from(info: AppExitInfo) -> Self { + codex_tui::AppExitInfo { + token_usage: info.token_usage, + conversation_id: info.conversation_id, + update_action: info.update_action.map(Into::into), + } + } +} + +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, + }) +} + +#[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, + hide_prompt_flag: Option, + available_models: Vec, +) -> bool { + if target_model == current_model || hide_prompt_flag.unwrap_or(false) { + return false; + } + + available_models + .iter() + .filter(|preset| preset.upgrade.is_some()) + .any(|preset| preset.model == current_model) +} + +fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> Option { + match migration_config_key { + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => { + config.notices.hide_gpt_5_1_codex_max_migration_prompt + } + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => config.notices.hide_gpt5_1_migration_prompt, + _ => None, + } +} + +async fn handle_model_migration_prompt_if_needed( + tui: &mut tui::Tui, + config: &mut Config, + model: &str, + app_event_tx: &AppEventSender, + auth_mode: Option, + models_manager: Arc, +) -> Option { + let available_models = models_manager.list_models(config).await; + 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, + }) = upgrade + { + if !migration_prompt_allows_auth_mode(auth_mode, migration_config_key.as_str()) { + return None; + } + + let target_model = target_model.to_string(); + let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key.as_str()); + if !should_show_model_migration_prompt( + model, + &target_model, + hide_prompt_flag, + available_models.clone(), + ) { + return None; + } + + let prompt_copy = migration_copy_for_config(migration_config_key.as_str()); + match run_model_migration_prompt(tui, prompt_copy).await { + ModelMigrationOutcome::Accepted => { + app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged { + migration_config: migration_config_key.to_string(), + }); + 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 { + migration_config: migration_config_key.to_string(), + }); + } + ModelMigrationOutcome::Exit => { + return Some(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + } + } + + 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>, + + // Pager overlay state (Transcript or Static like Diff) + pub(crate) overlay: Option, + pub(crate) deferred_history_lines: Vec>, + 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, + + // Esc-backtracking state grouped + pub(crate) backtrack: crate::app_backtrack::BacktrackState, + 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, + + pub(crate) skills: Option>, +} + +impl App { + async fn shutdown_current_conversation(&mut self) { + if let Some(conversation_id) = self.chat_widget.conversation_id() { + self.suppress_shutdown_complete = true; + self.chat_widget.submit_op(Op::Shutdown); + self.server.remove_conversation(&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, + resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, + is_first_run: bool, + ) -> Result { + use tokio_stream::StreamExt; + let (app_event_tx, mut app_event_rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(app_event_tx); + + let auth_mode = auth_manager.auth().map(|auth| auth.mode); + let conversation_manager = Arc::new(ConversationManager::new( + auth_manager.clone(), + SessionSource::Cli, + )); + let mut model = conversation_manager + .get_models_manager() + .get_model(&config.model, &config) + .await; + let exit_info = handle_model_migration_prompt_if_needed( + tui, + &mut config, + model.as_str(), + &app_event_tx, + auth_mode, + conversation_manager.get_models_manager(), + ) + .await; + if let Some(exit_info) = exit_info { + return Ok(exit_info); + } + if let Some(updated_model) = config.model.clone() { + model = updated_model; + } + + let skills_outcome = load_skills(&config); + if !skills_outcome.errors.is_empty() { + match run_skill_error_prompt(tui, &skills_outcome.errors).await { + SkillErrorPromptOutcome::Exit => { + return Ok(AppExitInfo { + token_usage: TokenUsage::default(), + conversation_id: None, + update_action: None, + }); + } + SkillErrorPromptOutcome::Continue => {} + } + } + + let skills = if config.features.enabled(Feature::Skills) { + Some(skills_outcome.skills.clone()) + } else { + None + }; + + let enhanced_keys_supported = tui.enhanced_keys_supported(); + let model_family = conversation_manager + .get_models_manager() + .construct_model_family(model.as_str(), &config) + .await; + let mut chat_widget = match resume_selection { + ResumeSelection::StartFresh | ResumeSelection::Exit => { + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), + feedback: feedback.clone(), + skills: skills.clone(), + is_first_run, + model_family: model_family.clone(), + }; + ChatWidget::new(init, conversation_manager.clone()) + } + ResumeSelection::Resume(path) => { + let resumed = conversation_manager + .resume_conversation_from_rollout( + config.clone(), + path.clone(), + auth_manager.clone(), + ) + .await + .wrap_err_with(|| { + format!("Failed to resume session from {}", path.display()) + })?; + let init = crate::chatwidget::ChatWidgetInit { + config: config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: app_event_tx.clone(), + initial_prompt: initial_prompt.clone(), + initial_images: initial_images.clone(), + enhanced_keys_supported, + auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), + feedback: feedback.clone(), + skills: skills.clone(), + is_first_run, + model_family: model_family.clone(), + }; + ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.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 mut app = Self { + server: conversation_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(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: feedback.clone(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills, + }; + + // 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, + 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.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 { + app.handle_event( + tui, + AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + latest_version, + crate::update_action::get_update_action(), + ))), + ) + .await?; + } + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + tui.frame_requester().schedule_frame(); + + while 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? + } + } {} + tui.terminal.clear()?; + Ok(AppExitInfo { + token_usage: app.token_usage(), + conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, + }) + } + + pub(crate) async fn handle_tui_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + 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::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 => { + self.chat_widget.maybe_post_pending_notification(tui); + if self + .chat_widget + .handle_paste_burst_tick(tui.frame_requester()) + { + return Ok(true); + } + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + self.chat_widget.render(frame.area(), frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + } + } + Ok(true) + } + + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + let model_family = self + .server + .get_models_manager() + .construct_model_family(self.current_model.as_str(), &self.config) + .await; + match event { + AppEvent::NewSession => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + 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(), + skills: self.skills.clone(), + is_first_run: false, + model_family: model_family.clone(), + }; + self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.current_model = model_family.get_model_slug().to_string(); + 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? + { + ResumeSelection::Resume(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .resume_conversation_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(), + skills: self.skills.clone(), + is_first_run: false, + model_family: model_family.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ); + self.current_model = model_family.get_model_slug().to_string(); + 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) => { + self.chat_widget.add_error_message(format!( + "Failed to resume session from {}: {err}", + path.display() + )); + } + } + } + ResumeSelection::Exit | ResumeSelection::StartFresh => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } + AppEvent::InsertHistoryCell(cell) => { + let cell: Arc = cell.into(); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_cells.push(cell.clone()); + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if !display.is_empty() { + // Only insert a separating blank line for new cells that are not + // part of an ongoing stream. Streaming continuations should not + // accrue extra blank lines between chunks. + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + } + 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(true); + } + self.chat_widget.handle_codex_event(event); + } + AppEvent::ConversationHistory(ev) => { + self.on_conversation_history_for_backtrack(tui, ev).await?; + } + AppEvent::ExitRequest => { + return Ok(false); + } + 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::UpdateReasoningEffort(effort) => { + self.on_update_reasoning_effort(effort); + } + AppEvent::UpdateModel(model) => { + let model_family = self + .server + .get_models_manager() + .construct_model_family(&model, &self.config) + .await; + self.chat_widget.set_model(&model, model_family); + 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::EnableWindowsSandboxForAgentMode { preset } => { + #[cfg(target_os = "windows")] + { + let profile = self.active_profile.as_deref(); + let feature_key = Feature::WindowsSandbox.key(); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_feature_enabled(feature_key, true) + .apply() + .await + { + Ok(()) => { + self.config.set_windows_sandbox_globally(true); + 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, + }, + )); + 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( + "Enabled experimental Windows 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; + } + } + 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 + ); + + self.config.sandbox_policy = policy.clone(); + #[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; + } + self.chat_widget.set_sandbox_policy(policy); + + // 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(true); + } + + 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.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 { migration_config } => { + if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home) + .set_hide_model_migration_prompt(&migration_config, true) + .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(true) + } + + 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); + } + } + // 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() => + { + // Delegate to helper for clarity; preserves behavior. + self.confirm_backtrack_from_main(); + } + 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, + }); + } + }); + } +} + +fn migration_prompt_allowed_auth_modes(migration_config_key: &str) -> Option<&'static [AuthMode]> { + match migration_config_key { + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_MIGRATION_AUTH_MODES), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => Some(&GPT_5_1_CODEX_MIGRATION_AUTH_MODES), + _ => None, + } +} + +fn migration_prompt_allows_auth_mode( + auth_mode: Option, + migration_config_key: &str, +) -> bool { + if let Some(allowed_modes) = migration_prompt_allowed_auth_modes(migration_config_key) { + match auth_mode { + None => true, + Some(mode) => allowed_modes.contains(&mode), + } + } else { + auth_mode != Some(AuthMode::ApiKey) + } +} + +#[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 codex_core::AuthManager; + use codex_core::CodexAuth; + use codex_core::ConversationManager; + 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::ConversationId; + use ratatui::prelude::Line; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + fn make_test_app() -> App { + let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender(); + let config = chat_widget.config_ref().clone(); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::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(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills: None, + } + } + + 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(); + let config = chat_widget.config_ref().clone(); + let current_model = chat_widget.get_model_family().get_model_slug().to_string(); + let server = Arc::new(ConversationManager::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(), + overlay: None, + deferred_history_lines: Vec::new(), + has_emitted_history_lines: false, + enhanced_keys_supported: false, + commit_anim_running: Arc::new(AtomicBool::new(false)), + backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), + pending_update_action: None, + suppress_shutdown_complete: false, + skip_world_writable_scan_once: false, + skills: None, + }, + rx, + op_rx, + ) + } + + fn all_model_presets() -> Vec { + codex_core::openai_models::model_presets::all_model_presets().clone() + } + + #[test] + fn model_migration_prompt_only_shows_for_deprecated_models() { + assert!(should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5-codex", + "gpt-5.1-codex", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5-codex-mini", + "gpt-5.1-codex-mini", + None, + all_model_presets() + )); + assert!(should_show_model_migration_prompt( + "gpt-5.1-codex", + "gpt-5.1-codex-max", + None, + all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.1-codex", + "gpt-5.1-codex", + None, + all_model_presets() + )); + } + + #[test] + fn model_migration_prompt_respects_hide_flag_and_self_target() { + assert!(!should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + Some(true), + all_model_presets() + )); + assert!(!should_show_model_migration_prompt( + "gpt-5.1", + "gpt-5.1", + None, + all_model_presets() + )); + } + + #[test] + fn update_reasoning_effort_updates_config() { + let mut app = make_test_app(); + 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) + ); + } + + #[test] + fn backtrack_selection_with_duplicate_history_targets_unique_turn() { + let mut app = make_test_app(); + + 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: ConversationId::new(), + 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, + skill_load_outcome: 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 the transcript after trimming for a fork, replaying history, and + // appending the edited turn. The session header separates the retained history + // from the forked conversation's replayed turns. + 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); + + app.backtrack.base_id = Some(ConversationId::new()); + app.backtrack.primed = true; + app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1); + + app.confirm_backtrack_from_main(); + + let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack"); + assert_eq!(nth, 1); + assert_eq!(prefill, "follow-up (edited)"); + } + + #[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(); + + let conversation_id = ConversationId::new(); + let event = SessionConfiguredEvent { + session_id: conversation_id, + 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, + skill_load_outcome: 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"), + } + } + + #[test] + fn session_summary_skip_zero_usage() { + assert!(session_summary(TokenUsage::default(), None).is_none()); + } + + #[test] + fn session_summary_includes_resume_hint() { + let usage = TokenUsage { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + ..Default::default() + }; + let conversation = + ConversationId::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()) + ); + } + + #[test] + fn gpt5_migration_allows_api_key_and_chatgpt() { + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + )); + } + + #[test] + fn gpt_5_1_codex_max_migration_limits_to_chatgpt() { + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + )); + } + + #[test] + fn other_migrations_block_api_key() { + assert!(!migration_prompt_allows_auth_mode( + Some(AuthMode::ApiKey), + "unknown" + )); + assert!(migration_prompt_allows_auth_mode( + Some(AuthMode::ChatGPT), + "unknown" + )); + } +} diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs new file mode 100644 index 00000000000..deb629765a2 --- /dev/null +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -0,0 +1,518 @@ +use std::any::TypeId; +use std::path::PathBuf; +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::ConversationPathResponseEvent; +use codex_protocol::ConversationId; +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 fork from. + pub(crate) base_id: Option, + /// Index in the transcript of the last user message. + pub(crate) nth_user_message: usize, + /// True when the transcript overlay is showing a backtrack preview. + pub(crate) overlay_preview_active: bool, + /// Pending fork request: (base_id, nth_user_message, prefill). + pub(crate) pending: Option<(ConversationId, usize, String)>, +} + +impl App { + /// Route overlay events when transcript overlay is active. + /// - If backtrack preview is active: Esc steps selection; Enter confirms. + /// - Otherwise: Esc begins preview; all other events forward to overlay. + /// interactions (Esc to step target, Enter to confirm) and overlay lifecycle. + 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::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 conversation history from the agent. + pub(crate) fn request_backtrack( + &mut self, + prefill: String, + base_id: ConversationId, + nth_user_message: usize, + ) { + self.backtrack.pending = Some((base_id, nth_user_message, prefill)); + if let Some(path) = self.chat_widget.rollout_path() { + let ev = ConversationPathResponseEvent { + conversation_id: base_id, + path, + }; + self.app_event_tx + .send(crate::app_event::AppEvent::ConversationHistory(ev)); + } else { + tracing::error!("rollout path unavailable; cannot backtrack"); + } + } + + /// 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. + 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_lines.is_empty() { + let lines = std::mem::take(&mut self.deferred_history_lines); + 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(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(next_selection); + tui.frame_requester().schedule_frame(); + } + + /// Apply a computed backtrack selection to the overlay and internal counter. + fn apply_backtrack_selection(&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<()> { + 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; + if let Some(base_id) = self.backtrack.base_id { + 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(); + self.close_transcript_overlay(tui); + self.request_backtrack(prefill, base_id, nth_user_message); + } + self.reset_backtrack_state(); + } + + /// 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(()) + } + + /// Confirm a primed backtrack from the main view (no overlay visible). + /// Computes the prefill from the selected user message and requests history. + pub(crate) fn confirm_backtrack_from_main(&mut self) { + if let Some(base_id) = self.backtrack.base_id { + let prefill = + nth_user_position(&self.transcript_cells, self.backtrack.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(); + self.request_backtrack(prefill, base_id, self.backtrack.nth_user_message); + } + self.reset_backtrack_state(); + } + + /// 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(); + } + + /// Handle a ConversationHistory response while a backtrack is pending. + /// If it matches the primed base session, fork and switch to the new conversation. + pub(crate) async fn on_conversation_history_for_backtrack( + &mut self, + tui: &mut tui::Tui, + ev: ConversationPathResponseEvent, + ) -> Result<()> { + if let Some((base_id, _, _)) = self.backtrack.pending.as_ref() + && ev.conversation_id == *base_id + && let Some((_, nth_user_message, prefill)) = self.backtrack.pending.take() + { + self.fork_and_switch_to_new_conversation(tui, ev, nth_user_message, prefill) + .await; + } + Ok(()) + } + + /// Fork the conversation using provided history and switch UI/state accordingly. + async fn fork_and_switch_to_new_conversation( + &mut self, + tui: &mut tui::Tui, + ev: ConversationPathResponseEvent, + nth_user_message: usize, + prefill: String, + ) { + let cfg = self.chat_widget.config_ref().clone(); + // Perform the fork via a thin wrapper for clarity/testability. + let result = self + .perform_fork(ev.path.clone(), nth_user_message, cfg.clone()) + .await; + match result { + Ok(new_conv) => { + self.install_forked_conversation(tui, cfg, new_conv, nth_user_message, &prefill) + } + Err(e) => tracing::error!("error forking conversation: {e:#}"), + } + } + + /// Thin wrapper around ConversationManager::fork_conversation. + async fn perform_fork( + &self, + path: PathBuf, + nth_user_message: usize, + cfg: codex_core::config::Config, + ) -> codex_core::error::Result { + self.server + .fork_conversation(nth_user_message, cfg, path) + .await + } + + /// Install a forked conversation into the ChatWidget and update UI to reflect selection. + fn install_forked_conversation( + &mut self, + tui: &mut tui::Tui, + cfg: codex_core::config::Config, + new_conv: codex_core::NewConversation, + nth_user_message: usize, + prefill: &str, + ) { + let conv = new_conv.conversation; + let session_configured = new_conv.session_configured; + let model_family = self.chat_widget.get_model_family(); + let init = crate::chatwidget::ChatWidgetInit { + config: cfg, + model_family: model_family.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(), + skills: self.skills.clone(), + is_first_run: false, + }; + self.chat_widget = + crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); + self.current_model = model_family.get_model_slug().to_string(); + // Trim transcript up to the selected user message and re-render it. + self.trim_transcript_for_backtrack(nth_user_message); + self.render_transcript_once(tui); + if !prefill.is_empty() { + self.chat_widget.set_composer_text(prefill.to_string()); + } + tui.frame_requester().schedule_frame(); + } + + /// Trim transcript_cells to preserve only content up to 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 new file mode 100644 index 00000000000..c92dab4b3a8 --- /dev/null +++ b/codex-rs/tui2/src/app_event.rs @@ -0,0 +1,185 @@ +use std::path::PathBuf; + +use codex_common::approval_presets::ApprovalPreset; +use codex_core::protocol::ConversationPathResponseEvent; +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; + +#[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, + + /// Request to exit the application gracefully. + ExitRequest, + + /// 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 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, + }, + + /// Enable the Windows sandbox feature and switch to Agent mode. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + EnableWindowsSandboxForAgentMode { + preset: ApprovalPreset, + }, + + /// 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 { + migration_config: 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, + + /// Forwarded conversation history snapshot from the current conversation. + ConversationHistory(ConversationPathResponseEvent), + + /// 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, + }, +} + +#[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 new file mode 100644 index 00000000000..c1427b3ff02 --- /dev/null +++ b/codex-rs/tui2/src/app_event_sender.rs @@ -0,0 +1,28 @@ +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 new file mode 100644 index 00000000000..b2d9fc1d196 --- /dev/null +++ b/codex-rs/tui2/src/ascii_animation.rs @@ -0,0 +1,111 @@ +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 new file mode 100644 index 00000000000..f1117fad91d --- /dev/null +++ b/codex-rs/tui2/src/bin/md-events2.rs @@ -0,0 +1,15 @@ +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/approval_overlay.rs b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs new file mode 100644 index 00000000000..d42861eb1d5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/approval_overlay.rs @@ -0,0 +1,717 @@ +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)) + .map(|prefix| { + let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); + 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: "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 new file mode 100644 index 00000000000..499801cbb09 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/bottom_pane_view.rs @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000000..ed498e949c6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -0,0 +1,3990 @@ +use crate::key_hint::has_ctrl_or_alt; +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::Style; +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::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::render_footer; +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_protocol::custom_prompts::CustomPrompt; +use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; + +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::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +/// 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), + Command(SlashCommand), + 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, + ctrl_c_quit_hint: bool, + 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, + // Non-bracketed paste burst tracker. + 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>, + context_window_percent: Option, + context_window_used_tokens: Option, + skills: Option>, + dismissed_skill_popup_token: Option, +} + +/// 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(), + ctrl_c_quit_hint: false, + 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, + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + custom_prompts: Vec::new(), + footer_mode: FooterMode::ShortcutSummary, + footer_hint_override: None, + context_window_percent: None, + context_window_used_tokens: None, + skills: None, + dismissed_skill_popup_token: None, + }; + // 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; + } + + 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 + } + + 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((w, h)) => { + tracing::info!("OK: {pasted}"); + let format_label = pasted_image_format(&path_buf).label(); + self.attach_image(path_buf, w, h, format_label); + true + } + Err(err) => { + tracing::trace!("ERR: {err}"); + false + } + } + } + + 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 { + self.paste_burst.clear_window_after_non_char(); + } + } + + /// 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; + } + + /// 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() + } + + /// Attempt to start a burst by retro-capturing recent chars before the cursor. + pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, _format_label: &str) { + let file_label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_string()); + let placeholder = format!("[{file_label} {width}x{height}]"); + // 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() + } + + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.handle_paste_burst_flush(Instant::now()) + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.paste_burst.is_active() + } + + 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); + } + } + + pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { + self.ctrl_c_quit_hint = show; + if show { + self.footer_mode = FooterMode::CtrlCReminder; + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + self.set_has_focus(has_focus); + } + + 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) { + 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] + 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 + } + + #[inline] + fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { + 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); + } + } + 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); + if let Ok((w, h)) = image::image_dimensions(&path_buf) { + // Remove the current @token (mirror logic from insert_selected_path without inserting text) + // using the flat text and byte-offset cursor API. + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + // 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); + + let format_label = match Path::new(&sel_path) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + { + Some(ext) if ext == "png" => "PNG", + Some(ext) if ext == "jpg" || ext == "jpeg" => "JPEG", + _ => "IMG", + }; + self.attach_image(path_buf, w, h, format_label); + // Add a trailing space to keep typing fluid. + self.textarea.insert_str(" "); + } else { + // Fallback to plain path insertion if metadata read fails. + self.insert_selected_path(&sel_path); + } + } else { + // Non-image: inserting file path. + self.insert_selected_path(&sel_path); + } + // 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); + } + 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::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()) + } + + /// 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); + } + + /// 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() => { + self.app_event_tx.send(AppEvent::ExitRequest); + (InputResult::None, true) + } + // ------------------------------------------------------------- + // 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::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + // 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 to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter should still dispatch the command rather than submit + // literal text. + 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)) = built_in_slash_commands() + .into_iter() + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + // If we're in a paste-like burst capture, treat Enter as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat Enter as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.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 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(); + } + + // During a paste-like burst, treat Enter as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + 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 = built_in_slash_commands() + .into_iter() + .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 (InputResult::None, true); + } + } + } + + 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 (InputResult::None, true); + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return (InputResult::None, true); + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + input => self.handle_input_basic(input), + } + } + + 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, + } + } + + /// Handle generic Input events that modify the textarea content. + fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + // If we have a buffered non-bracketed paste burst and enough time has + // elapsed since the last char, flush it before handling a new input. + let now = Instant::now(); + 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.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. + if let KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } = input + { + let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); + if !has_ctrl_or_alt { + // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be + // misclassified by paste heuristics. Flush any active burst buffer and insert + // non-ASCII characters directly. + 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.begin_with_retro_grabbed(grab.grabbed, now); + 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); + } + } + + // For non-char inputs (or after flushing), handle normally. + // Special handling for backspace on placeholders + if let KeyEvent { + code: KeyCode::Backspace, + .. + } = input + && self.try_remove_any_placeholder_at_cursor() + { + return (InputResult::None, true); + } + + // Normal input handling + self.textarea.input(input); + let text_after = self.textarea.text(); + + // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. + let crossterm::event::KeyEvent { + code, modifiers, .. + } = input; + match code { + KeyCode::Char(_) => { + let has_ctrl_or_alt = 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(); + } + } + + // Check if any placeholders were removed and remove their corresponding pending pastes + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + + // Keep attached images in proportion to how many matching placeholders exist in the text. + // This handles duplicate placeholders that share the same visible label. + if !self.attached_images.is_empty() { + let mut needed: HashMap = HashMap::new(); + for img in &self.attached_images { + needed + .entry(img.placeholder.clone()) + .or_insert_with(|| text_after.matches(&img.placeholder).count()); + } + + let mut used: HashMap = HashMap::new(); + let mut kept: Vec = Vec::with_capacity(self.attached_images.len()); + for img in self.attached_images.drain(..) { + let total_needed = *needed.get(&img.placeholder).unwrap_or(&0); + let used_count = used.entry(img.placeholder.clone()).or_insert(0); + if *used_count < total_needed { + kept.push(img); + *used_count += 1; + } + } + self.attached_images = kept; + } + + (InputResult::None, true) + } + + /// Attempts to remove an image or paste placeholder if the cursor is at the end of one. + /// Returns true if a placeholder was removed. + fn try_remove_any_placeholder_at_cursor(&mut self) -> bool { + // Clamp the cursor to a valid char boundary to avoid panics when slicing. + let text = self.textarea.text(); + let p = Self::clamp_to_char_boundary(text, self.textarea.cursor()); + + // Try image placeholders first + let mut out: Option<(usize, String)> = None; + // Detect if the cursor is at the end of any image placeholder. + // If duplicates exist, remove the specific occurrence's mapping. + for (i, img) in self.attached_images.iter().enumerate() { + let ph = &img.placeholder; + if p < ph.len() { + continue; + } + let start = p - ph.len(); + if text.get(start..p) != Some(ph.as_str()) { + continue; + } + + // Count the number of occurrences of `ph` before `start`. + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < start { + let segment = match text.get(search_pos..start) { + Some(s) => s, + None => break, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break; + } + } + + // Remove the occ_before-th attached image that shares this placeholder label. + out = if let Some((remove_idx, _)) = self + .attached_images + .iter() + .enumerate() + .filter(|(_, img2)| img2.placeholder == *ph) + .nth(occ_before) + { + Some((remove_idx, ph.clone())) + } else { + Some((i, ph.clone())) + }; + break; + } + if let Some((idx, placeholder)) = out { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.attached_images.remove(idx); + return true; + } + + // Also handle when the cursor is at the START of an image placeholder. + // let result = 'out: { + let out: Option<(usize, String)> = 'out: { + for (i, img) in self.attached_images.iter().enumerate() { + let ph = &img.placeholder; + if p + ph.len() > text.len() { + continue; + } + if text.get(p..p + ph.len()) != Some(ph.as_str()) { + continue; + } + + // Count occurrences of `ph` before `p`. + let mut occ_before = 0usize; + let mut search_pos = 0usize; + while search_pos < p { + let segment = match text.get(search_pos..p) { + Some(s) => s, + None => break 'out None, + }; + if let Some(found) = segment.find(ph) { + occ_before += 1; + search_pos += found + ph.len(); + } else { + break 'out None; + } + } + + if let Some((remove_idx, _)) = self + .attached_images + .iter() + .enumerate() + .filter(|(_, img2)| img2.placeholder == *ph) + .nth(occ_before) + { + break 'out Some((remove_idx, ph.clone())); + } else { + break 'out Some((i, ph.clone())); + } + } + None + }; + + if let Some((idx, placeholder)) = out { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.attached_images.remove(idx); + return true; + } + + // Then try pasted-content placeholders + if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { + if p < ph.len() { + return None; + } + let start = p - ph.len(); + if text.get(start..p) == Some(ph.as_str()) { + Some(ph.clone()) + } else { + None + } + }) { + self.textarea.replace_range(p - placeholder.len()..p, ""); + self.pending_pastes.retain(|(ph, _)| ph != &placeholder); + return true; + } + + // Also handle when the cursor is at the START of a pasted-content placeholder. + if let Some(placeholder) = self.pending_pastes.iter().find_map(|(ph, _)| { + if p + ph.len() > text.len() { + return None; + } + if text.get(p..p + ph.len()) == Some(ph.as_str()) { + Some(ph.clone()) + } else { + None + } + }) { + self.textarea.replace_range(p..p + placeholder.len(), ""); + self.pending_pastes.retain(|(ph, _)| ph != &placeholder); + return true; + } + + false + } + + 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(); + + if !toggles { + return false; + } + + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + 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, + context_window_percent: self.context_window_percent, + context_window_used_tokens: self.context_window_used_tokens, + } + } + + fn footer_mode(&self) -> FooterMode { + match self.footer_mode { + FooterMode::EscHint => FooterMode::EscHint, + FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, + FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, + FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, + other => other, + } + } + + fn custom_footer_height(&self) -> Option { + self.footer_hint_override + .as_ref() + .map(|items| if items.is_empty() { 0 } else { 1 }) + } + + fn sync_popups(&mut self) { + let file_token = Self::current_at_token(&self.textarea); + 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 = built_in_slash_commands() + .into_iter() + .any(|(cmd_name, _)| cmd_name.starts_with(name)); + + if builtin_match { + return true; + } + + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + self.custom_prompts + .iter() + .any(|p| format!("{prompt_prefix}{}", p.name).starts_with(name)) + } + + /// 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 mut command_popup = + CommandPopup::new(self.custom_prompts.clone(), skills_enabled); + command_popup.on_composer_text_change(first_line.to_string()); + self.active_popup = ActivePopup::Command(command_popup); + } + } + } + } + + 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; + } + + 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)> { + 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 let Some(items) = self.footer_hint_override.as_ref() { + if !items.is_empty() { + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(Span::styled(key.clone(), Style::default().bold())); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + let mut custom_rect = hint_rect; + if custom_rect.width > 2 { + custom_rect.x += 2; + custom_rect.width = custom_rect.width.saturating_sub(2); + } + Line::from(spans).render_ref(custom_rect, buf); + } + } 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() { + buf.set_span( + textarea_rect.x - LIVE_PREFIX_COLS, + textarea_rect.y, + &"›".bold(), + 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 placeholder = Span::from(self.placeholder_text.as_str()).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:?}", + ); + } + + 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.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { + composer.set_task_running(true); + composer.set_ctrl_c_quit_hint(true, true); + }); + + snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { + composer.set_ctrl_c_quit_hint(true, 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_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()) + ); + } + + #[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, + ); + + 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"); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + assert_eq!(composer.textarea.text(), "h?"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + } + + #[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, + ); + + 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}", + ); + } + } + + #[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, + ); + + 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"), + } + } + + #[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, + ); + + 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, + ); + + // Ensure composer is empty and press Enter. + assert!(composer.textarea.text().is_empty()); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::None => {} + other => panic!("expected None for empty enter, got: {other:?}"), + } + } + + #[test] + fn handle_paste_large_uses_placeholder_and_replaces_on_submit() { + use crossterm::event::KeyCode; + 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 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()); + } + + #[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.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, + ); + + // 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, + ); + 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, + ); + + // 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, + ); + 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(); + } + } + + #[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::Submitted(text) => { + panic!("expected command dispatch, but composer submitted literal text: {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::Submitted(text) => { + panic!("expected command dispatch after Tab completion, got literal submit: {text}") + } + 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::Submitted(text) => { + panic!("expected command dispatch, but composer submitted literal text: {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(), "@"); + } + + #[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, + ); + + // 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), + ] + ); + } + + #[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); + } + + #[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, + ); + let path = PathBuf::from("/tmp/image1.png"); + composer.attach_image(path.clone(), 32, 16, "PNG"); + composer.handle_paste(" hi".into()); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "[image1.png 32x16] 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, + ); + let path = PathBuf::from("/tmp/image2.png"); + composer.attach_image(path.clone(), 10, 5, "PNG"); + let (result, _) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match result { + InputResult::Submitted(text) => assert_eq!(text, "[image2.png 10x5]"), + _ => 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(), 20, 10, "PNG"); + let placeholder = composer.attached_images[0].placeholder.clone(); + + // Case 1: backspace at end + composer.textarea.move_cursor_to_end_of_line(false); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.textarea.text().contains(&placeholder)); + assert!(composer.attached_images.is_empty()); + + // Re-add and test backspace in middle: should break the placeholder string + // and drop the image mapping (same as text placeholder behavior). + composer.attach_image(path, 20, 10, "PNG"); + let placeholder2 = composer.attached_images[0].placeholder.clone(); + // Move cursor to roughly middle of placeholder + if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { + let mid_pos = start_pos + (placeholder2.len() / 2); + composer.textarea.set_cursor(mid_pos); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(!composer.textarea.text().contains(&placeholder2)); + assert!(composer.attached_images.is_empty()); + } else { + panic!("Placeholder not found in textarea"); + } + } + + #[test] + fn 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, 10, 5, "PNG"); + // 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_multibyte.png 10x5]") + ); + } + + #[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, 10, 5, "PNG"); + // separate placeholders with a space for clarity + composer.handle_paste(" ".into()); + composer.attach_image(path2.clone(), 10, 5, "PNG"); + + 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(&placeholder1).count(), + "first placeholder removed" + ); + assert_eq!( + 1, + new_text.matches(&placeholder2).count(), + "second placeholder remains" + ); + assert_eq!( + vec![AttachedImage { + path: path2, + placeholder: "[image_dup2.png 10x5]".to_string() + }], + composer.attached_images, + "one image mapping remains" + ); + } + + #[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("[codex_tui_test_paste_image.png 3x2] ") + ); + + 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, + ); + + // 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_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_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()); + } + + #[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, + ); + + // 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 + .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.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_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_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_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_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); + } + + #[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" + ); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + 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" + ); + } + + #[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()); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + 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')); + } + + #[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: 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" + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs new file mode 100644 index 00000000000..991283a5663 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/chat_composer_history.rs @@ -0,0 +1,300 @@ +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 new file mode 100644 index 00000000000..8aca5c4a625 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -0,0 +1,376 @@ +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; + +/// 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, +} + +impl CommandPopup { + pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .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. Sorted by ascending + /// score, then by name for stability. + 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)); + } + } + // When filtering, sort by ascending score and then by name for stability. + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = match a.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + let bn = match b.0 { + CommandItem::Builtin(c) => c.command(), + CommandItem::UserPrompt(i) => &self.prompts[i].name, + }; + an.cmp(bn) + }) + }); + 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(), false); + // 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(), false); + 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(), false); + popup.on_composer_text_change("/mo".to_string()); + let matches = popup.filtered_items(); + match matches.first() { + Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"), + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt ranked before '/model' for '/mo'") + } + None => panic!("expected at least one match for '/mo'"), + } + } + + #[test] + fn prompt_discovery_lists_custom_prompts() { + let prompts = vec![ + 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, false); + 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, + }], + false, + ); + 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, + }], + false, + ); + 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, + }], + false, + ); + 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")); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs new file mode 100644 index 00000000000..e9f0ee697f9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/custom_prompt_view.rs @@ -0,0 +1,247 @@ +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 new file mode 100644 index 00000000000..c563ab8e90b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/feedback_view.rs @@ -0,0 +1,559 @@ +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() + } +} + +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 new file mode 100644 index 00000000000..064e4f01370 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,154 @@ +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 new file mode 100644 index 00000000000..d47ffec98b6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -0,0 +1,530 @@ +#[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::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; + +#[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) context_window_percent: Option, + pub(crate) context_window_used_tokens: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + CtrlCReminder, + ShortcutSummary, + ShortcutOverlay, + EscHint, + ContextOnly, +} + +pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { + if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) { + return current; + } + + match current { + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => 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::CtrlCReminder + | 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); +} + +fn footer_lines(props: FooterProps) -> Vec> { + // 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. + match props.mode { + FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { + is_task_running: props.is_task_running, + })], + 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(), + ]); + 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, + }; + shortcut_overlay_lines(state) + } + FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::ContextOnly => vec![context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + )], + } +} + +#[derive(Clone, Copy, Debug)] +struct CtrlCReminderState { + is_task_running: bool, +} + +#[derive(Clone, Copy, Debug)] +struct ShortcutsState { + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, + is_wsl: bool, +} + +fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { + let action = if state.is_task_running { + "interrupt" + } else { + "quit" + }; + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + format!(" again to {action}").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 newline = 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(""); + + for descriptor in SHORTCUTS { + if let Some(text) = descriptor.overlay_entry(state) { + match descriptor.id { + ShortcutId::Commands => commands = text, + ShortcutId::InsertNewline => newline = 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, + } + } + } + + let ordered = vec![ + commands, + newline, + file_paths, + paste_image, + edit_previous, + quit, + Line::from(""), + 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, + InsertNewline, + FilePaths, + PasteImage, + EditPrevious, + Quit, + ShowTranscript, +} + +#[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, +} + +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, + } + } +} + +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::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::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", + }, +]; + +#[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, + context_window_percent: None, + context_window_used_tokens: 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, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_idle", + FooterProps { + mode: FooterMode::CtrlCReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_ctrl_c_quit_running", + FooterProps { + mode: FooterMode::CtrlCReminder, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_idle", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_esc_hint_primed", + FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_context_running", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: true, + context_window_percent: Some(72), + context_window_used_tokens: None, + }, + ); + + snapshot_footer( + "footer_context_tokens_used", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: Some(123_456), + }, + ); + } +} diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs new file mode 100644 index 00000000000..d23fd8ed3b6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -0,0 +1,794 @@ +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 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 actions: Vec, + pub dismiss_on_select: bool, + pub search_value: Option, +} + +pub(crate) struct SelectionViewParams { + pub title: Option, + pub subtitle: 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_hint: None, + items: Vec::new(), + is_searchable: false, + search_placeholder: None, + header: Box::new(()), + initial_selected_idx: None, + } + } +} + +pub(crate) struct ListSelectionView { + 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_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 name_with_marker = if item.is_current { + format!("{name} (current)") + } else { + item.name.clone() + }; + 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 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 [content_area, footer_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), + ]) + .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 let Some(hint) = &self.footer_hint { + let hint_area = Rect { + x: footer_area.x + 2, + y: footer_area.y, + width: footer_area.width.saturating_sub(2), + height: footer_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 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 new file mode 100644 index 00000000000..554810de7f0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -0,0 +1,814 @@ +//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. +use std::path::PathBuf; + +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::queued_user_messages::QueuedUserMessages; +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 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_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; + +#[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. +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, + ctrl_c_quit_hint: 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, + ctrl_c_quit_hint: 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 status_widget(&self) -> Option<&StatusIndicatorWidget> { + self.status.as_ref() + } + + #[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 + } + } + + /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a + /// chance to consume the event (e.g. to dismiss itself). + 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_ctrl_c_quit_hint(); + } + event + } else if self.composer_is_empty() { + CancellationEvent::NotHandled + } else { + self.view_stack.pop(); + self.clear_composer_for_ctrl_c(); + self.show_ctrl_c_quit_hint(); + 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(); + } + + 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 animated header shown to the left of the brackets in the + /// status indicator (defaults to "Working"). No-ops if the status + /// indicator is not active. + pub(crate) fn update_status_header(&mut self, header: String) { + if let Some(status) = self.status.as_mut() { + status.update_header(header); + self.request_redraw(); + } + } + + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { + self.ctrl_c_quit_hint = true; + self.composer + .set_ctrl_c_quit_hint(true, self.has_input_focus); + self.request_redraw(); + } + + pub(crate) fn clear_ctrl_c_quit_hint(&mut self) { + if self.ctrl_c_quit_hint { + self.ctrl_c_quit_hint = false; + self.composer + .set_ctrl_c_quit_hint(false, self.has_input_focus); + self.request_redraw(); + } + } + + #[cfg(test)] + pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool { + self.ctrl_c_quit_hint + } + + #[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(); + } + + /// 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 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() + } + + 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, + width: u32, + height: u32, + format_label: &str, + ) { + if self.view_stack.is_empty() { + self.composer + .attach_image(path, width, height, format_label); + 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_and_shows_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.ctrl_c_quit_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 new file mode 100644 index 00000000000..49377cb21c5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -0,0 +1,267 @@ +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); + +#[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) + } + + /// Entry point: decide how to treat a plain char with current timing. + pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { + match self.last_plain_char_time { + Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { + self.consecutive_plain_char_burst = + self.consecutive_plain_char_burst.saturating_add(1) + } + _ => self.consecutive_plain_char_burst = 1, + } + self.last_plain_char_time = Some(now); + + if 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 + } + + /// Flush the buffered burst if the inter-key timeout has elapsed. + /// + /// Returns Some(String) when either: + /// - We were actively buffering paste-like input and the buffer is now + /// emitted as a single pasted string; or + /// - We had saved a single fast first-char with no subsequent burst and we + /// now emit that char as normal typed input. + /// + /// Returns None if the timeout has not elapsed or there is nothing to flush. + pub fn flush_if_due(&mut self, now: Instant) -> FlushResult { + let timed_out = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL); + if timed_out && self.is_active_internal() { + self.active = false; + let out = std::mem::take(&mut self.buffer); + 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) +} diff --git a/codex-rs/tui2/src/bottom_pane/popup_consts.rs b/codex-rs/tui2/src/bottom_pane/popup_consts.rs new file mode 100644 index 00000000000..2cabe389b1b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/popup_consts.rs @@ -0,0 +1,21 @@ +//! 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 new file mode 100644 index 00000000000..48c3cedfab8 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/prompt_args.rs @@ -0,0 +1,406 @@ +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 new file mode 100644 index 00000000000..ae33aeada47 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/queued_user_messages.rs @@ -0,0 +1,157 @@ +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 new file mode 100644 index 00000000000..a9728d1a0db --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/scroll_state.rs @@ -0,0 +1,115 @@ +/// 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 new file mode 100644 index 00000000000..5107ab0ca91 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/selection_popup_common.rs @@ -0,0 +1,269 @@ +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 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 +} + +/// 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); + } + } +} + +/// 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))); + total = total.saturating_add(word_wrap_line(&full_line, opts).len() 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 new file mode 100644 index 00000000000..3e0f79f84bb --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/skill_popup.rs @@ -0,0 +1,142 @@ +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::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use codex_common::fuzzy_match::fuzzy_match; +use codex_core::skills::model::SkillMetadata; + +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()); + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + 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 slug = skill + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&skill.name); + let name = format!("{} ({slug})", skill.name); + let description = skill.description.clone(); + 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() { + if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + out.push((idx, Some(indices), score)); + } + } + + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = &self.skills[a.0].name; + let bn = &self.skills[b.0].name; + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + 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 skills", + ); + } +} 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 new file mode 100644 index 00000000000..00821b7910d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..1a34b29f924 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..d323fda148b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui2/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_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 new file mode 100644 index 00000000000..d9395f2b055 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..9e93b8d6833 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..1d16779b01f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..9e93b8d6833 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..0aa72ca002d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..1d16779b01f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..178182bfd77 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,16 @@ +--- +source: tui2/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ 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 new file mode 100644 index 00000000000..3b7711d75f0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..426afbec6ec --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..dc66d149e47 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..daedb3d8889 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,10 @@ +--- +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 new file mode 100644 index 00000000000..8f669e1cb93 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..f3c3a319bcd --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..2ab262c229e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..6bd68462029 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..1ec33c54eee --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +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_tokens_used.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap new file mode 100644 index 00000000000..e31cf10f068 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +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_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 new file mode 100644 index 00000000000..157853e73d5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..98bc87b38ee --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" ctrl + c again to interrupt " 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 new file mode 100644 index 00000000000..201bec4f629 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..0bc46a989af --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +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_context_running.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap new file mode 100644 index 00000000000..2dd8738fe0c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..286acadd8b9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..47508f32406 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,8 @@ +--- +source: tui2/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands shift + enter for newline " +" @ 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__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 new file mode 100644 index 00000000000..b46a229ad4c --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..bcdc8a35615 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +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 new file mode 100644 index 00000000000..2cc2578c56a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +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 new file mode 100644 index 00000000000..88a5d14932f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..c715e81c9a9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 00000000000..1e88bfb5b19 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +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 new file mode 100644 index 00000000000..8160a886db5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 00000000000..9b1ef9e5c60 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +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 new file mode 100644 index 00000000000..f46cf990fa6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +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 new file mode 100644 index 00000000000..71504561db3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..f6c157922a3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +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 new file mode 100644 index 00000000000..6ac4296833e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,12 @@ +--- +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 new file mode 100644 index 00000000000..e4cc9ffefd5 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..53e0aee4cf9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..49ffb0d4c8f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..7ecc5bba719 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..9cad17b8648 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..2fce42cc26b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..9cad17b8648 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..67e616e917f --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..2fce42cc26b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..3b6782d06d6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" / for commands shift + enter for newline " +" @ 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_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 new file mode 100644 index 00000000000..6b018021ece --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..40098faee01 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..661e82e3ad1 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_mo.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..df8ea36e638 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..498ed769366 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..465f0f9c4f3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bad_result.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..a0b5660135b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_bug.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..73074d61faa --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_good_result.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..80e4ffeffe1 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_other.snap @@ -0,0 +1,9 @@ +--- +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 new file mode 100644 index 00000000000..bafa94b09de --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +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 new file mode 100644 index 00000000000..a77ca5565b6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_context_tokens_used.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..31a1b743b8e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..9979372a1b9 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..b2333b025f6 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..20f9b178b4b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..d05ac90a911 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..c95a5dc0b3d --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..264515a6c2b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/bottom_pane/footer.rs +expression: terminal.backend() +--- +" / for commands shift + enter for newline " +" @ 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_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 new file mode 100644 index 00000000000..be81978c896 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_model_picker_width_80.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..3ce6a3c45ff --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_narrow_width_preserves_rows.snap @@ -0,0 +1,16 @@ +--- +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 new file mode 100644 index 00000000000..512f6bbca63 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -0,0 +1,12 @@ +--- +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 new file mode 100644 index 00000000000..ddd0f90cd87 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..cf1f7248b32 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 00000000000..5e403e1bddf --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 00000000000..4484509695b --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +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 new file mode 100644 index 00000000000..16d63612574 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__message_queue__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +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 new file mode 100644 index 00000000000..d2afbf7dbd0 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_many_line_message.snap @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 00000000000..9d7527d16fa --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_more_than_three_messages.snap @@ -0,0 +1,30 @@ +--- +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 new file mode 100644 index 00000000000..d47fa978634 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_one_message.snap @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 00000000000..1f020fec64e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_two_messages.snap @@ -0,0 +1,22 @@ +--- +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 new file mode 100644 index 00000000000..4f2917a6c42 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__queued_user_messages__tests__render_wrapped_message.snap @@ -0,0 +1,25 @@ +--- +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__queued_messages_visible_when_status_hidden_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap new file mode 100644 index 00000000000..123a5eb3a3e --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__queued_messages_visible_when_status_hidden_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/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_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 new file mode 100644 index 00000000000..86e3da45730 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -0,0 +1,10 @@ +--- +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_and_queued_messages_snapshot.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap new file mode 100644 index 00000000000..27df671e4d3 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_queued_messages_snapshot.snap @@ -0,0 +1,12 @@ +--- +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__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 new file mode 100644 index 00000000000..52f96e8557a --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..2fd415c7f65 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -0,0 +1,2015 @@ +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.wrap_cache.replace(None); + self.preferred_col = None; + self.elements.clear(); + 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 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_nearest_boundary(&self, mut pos: usize) -> usize { + if pos > self.text.len() { + pos = self.text.len(); + } + 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 { + e.range.start + } else { + e.range.end + } + } else { + pos + } + } + + fn clamp_pos_for_insertion(&self, pos: usize) -> usize { + // 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 { + e.range.start + } else { + 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 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 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 new file mode 100644 index 00000000000..ea29c00d937 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget.rs @@ -0,0 +1,3463 @@ +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 codex_app_server_protocol::AuthMode; +use codex_backend_client::Client as BackendClient; +use codex_core::config::Config; +use codex_core::config::types::Notifications; +use codex_core::git_info::current_branch_name; +use codex_core::git_info::local_git_branches; +use codex_core::openai_models::model_family::ModelFamily; +use codex_core::openai_models::models_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::ExecCommandSource; +use codex_core::protocol::ExitedReviewModeEvent; +use codex_core::protocol::ListCustomPromptsResponseEvent; +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::StreamErrorEvent; +use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol::TurnAbortReason; +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::SkillMetadata; +use codex_protocol::ConversationId; +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::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_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::InputResult; +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::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::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 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::ConversationManager; +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; + +#[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) skills: Option>, + pub(crate) is_first_run: bool, + pub(crate) model_family: ModelFamily, +} + +#[derive(Default)] +enum RateLimitSwitchPromptState { + #[default] + Idle, + Pending, + Shown, +} + +pub(crate) struct ChatWidget { + app_event_tx: AppEventSender, + codex_op_tx: UnboundedSender, + bottom_pane: BottomPane, + active_cell: Option>, + config: Config, + model_family: ModelFamily, + 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, + mcp_startup_status: Option>, + // 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, + 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, + // 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 to add a final message separator after the last message + needs_final_message_separator: 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, +} + +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 { + 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); + } + } + + fn set_status_header(&mut self, header: String) { + self.current_status_header = header.clone(); + self.bottom_pane.update_status_header(header); + } + + fn restore_retry_status_header_if_present(&mut self) { + if let Some(header) = self.retry_status_header.take() + && self.current_status_header != header + { + 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.conversation_id = Some(event.session_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.session_header.set_model(&model_for_header); + self.add_to_history(history_cell::new_session_info( + &self.config, + &model_for_header, + event, + self.show_welcome_banner, + )); + 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); + 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(); + } + } + + 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) { + let reasoning_summary_format = self.get_model_family().reasoning_summary_format; + // 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(), + reasoning_summary_format, + ); + 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.bottom_pane.clear_ctrl_c_quit_hint(); + self.bottom_pane.set_task_running(true); + 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.bottom_pane.set_task_running(false); + 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 + .or(self.model_family.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.model_family.get_model_slug() != 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 running UI state. + 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.bottom_pane.set_task_running(false); + 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(); + } + pub(crate) fn get_model_family(&self) -> ModelFamily { + self.model_family.clone() + } + + 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.bottom_pane.set_task_running(true); + 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.bottom_pane.set_task_running(false); + 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: codex_core::protocol::ExecCommandOutputDeltaEvent, + ) { + // TODO: Handle streaming exec output if/when implemented + } + + 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(format!( + "Searched: {}", + ev.query + ))); + } + + 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_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) { + if self.retry_status_header.is_none() { + self.retry_status_header = Some(self.current_status_header.clone()); + } + self.set_status_header(message); + } + + /// Periodic tick to commit at most one queued line to history with a small delay, + /// animating the output. + pub(crate) fn on_commit_tick(&mut self) { + 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); + } + 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. + self.flush_active_cell(); + + if self.stream_controller.is_none() { + if self.needs_final_message_separator { + 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.stream_controller = Some(StreamController::new( + self.last_rendered_width.get().map(|w| w.saturating_sub(2)), + )); + } + if let Some(controller) = self.stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + } + 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(); + } + } + } + + 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)); + } + } + + 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; + } 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.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.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); + } + } + + pub(crate) fn new( + common: ChatWidgetInit, + conversation_manager: Arc, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + skills, + is_first_run, + model_family, + } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut config = config; + config.model = Some(model_slug.clone()); + let mut rng = rand::rng(); + let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + + 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, + }), + active_cell: None, + config, + model_family, + auth_manager, + models_manager, + session_header: SessionHeader::new(model_slug), + 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, + mcp_startup_status: None, + 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, + queued_user_messages: VecDeque::new(), + show_welcome_banner: is_first_run, + suppress_session_configured_redraw: false, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + }; + + widget.prefetch_rate_limits(); + + 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 { + config, + frame_requester, + app_event_tx, + initial_prompt, + initial_images, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + skills, + model_family, + .. + } = common; + let model_slug = model_family.get_model_slug().to_string(); + let mut rng = rand::rng(); + let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); + + 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, + }), + active_cell: None, + config, + model_family, + auth_manager, + models_manager, + session_header: SessionHeader::new(model_slug), + 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, + mcp_startup_status: None, + 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, + queued_user_messages: VecDeque::new(), + show_welcome_banner: false, + suppress_session_configured_redraw: true, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + }; + + widget.prefetch_rate_limits(); + + 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.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && c.eq_ignore_ascii_case(&'v') => + { + match paste_image_to_temp_png() { + Ok((path, info)) => { + self.attach_image( + path, + info.width, + info.height, + info.encoded_format.label(), + ); + } + 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_ctrl_c_quit_hint(); + } + _ => {} + } + + match key_event { + 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) => { + // If a task is running, queue the user input to be sent after the turn completes. + 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::None => {} + } + } + } + } + + pub(crate) fn attach_image( + &mut self, + path: PathBuf, + width: u32, + height: u32, + format_label: &str, + ) { + tracing::info!( + "attach_image path={path:?} width={width} height={height} format={format_label}", + ); + self.bottom_pane + .attach_image(path, width, height, format_label); + 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 => { + // 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::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::Approvals => { + self.open_approvals_popup(); + } + SlashCommand::Quit | SlashCommand::Exit => { + self.request_exit(); + } + 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_exit(); + } + 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::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")), + }), + })); + } + } + } + + 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) { + if !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)); + } + + fn queue_user_message(&mut self, user_message: UserMessage) { + if 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 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; + } + + if !text.is_empty() { + items.push(UserInput::Text { text: text.clone() }); + } + + for path in image_paths { + items.push(UserInput::LocalImage { path }); + } + + self.codex_op_tx + .send(Op::UserInput { items }) + .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::TaskStarted(_) => self.on_task_started(), + EventMsg::TaskComplete(TaskCompleteEvent { 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::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, .. }) => { + self.on_stream_error(message) + } + 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::RawResponseItem(_) + | EventMsg::ItemStarted(_) + | EventMsg::ItemCompleted(_) + | EventMsg::AgentMessageContentDelta(_) + | EventMsg::ReasoningContentDelta(_) + | EventMsg::ReasoningRawContentDelta(_) => {} + } + } + + 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))); + } + } else { + let message_text = + codex_core::review_format::format_review_findings_block(&output.findings, None); + let mut message_lines: Vec> = Vec::new(); + append_markdown(&message_text, None, &mut message_lines); + let body_cell = AgentMessageCell::new(message_lines, true); + self.app_event_tx + .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); + } + } + + 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(); + if !message.is_empty() { + self.add_to_history(history_cell::new_user_prompt(message.to_string())); + } + } + + fn request_exit(&self) { + self.app_event_tx.send(AppEvent::ExitRequest); + } + + fn request_redraw(&mut self) { + self.frame_requester.schedule_frame(); + } + + 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 (total_usage, context_usage) = if let Some(ti) = &self.token_info { + (&ti.total_token_usage, Some(&ti.last_token_usage)) + } else { + (&default_usage, Some(&default_usage)) + }; + self.add_to_history(crate::status::new_status_output( + &self.config, + self.auth_manager.as_ref(), + &self.model_family, + total_usage, + context_usage, + &self.conversation_id, + self.rate_limit_snapshot.as_ref(), + self.plan_type, + Local::now(), + self.model_family.get_model_slug(), + )); + } + fn stop_rate_limit_poller(&mut self) { + if let Some(handle) = self.rate_limit_poller.take() { + handle.abort(); + } + } + + fn prefetch_rate_limits(&mut self) { + self.stop_rate_limit_poller(); + + let Some(auth) = self.auth_manager.auth() else { + return; + }; + if auth.mode != AuthMode::ChatGPT { + return; + } + + let base_url = self.config.chatgpt_base_url.clone(); + let app_event_tx = self.app_event_tx.clone(); + + let handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + + loop { + if let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth.clone()).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().ok()?; + models + .iter() + .find(|preset| 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, + })); + 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) { + let current_model = self.model_family.get_model_slug().to_string(); + let presets: Vec = + // todo(aibrahim): make this async function + match self.models_manager.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment." + .to_string(), + None, + ); + return; + } + }; + + let current_label = presets + .iter() + .find(|preset| preset.model == current_model) + .map(|preset| preset.display_name.to_string()) + .unwrap_or_else(|| current_model.clone()); + + 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, + description, + is_current: model == current_model, + 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 current_model = self.model_family.get_model_slug().to_string(); + 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 = preset.model == 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.to_string(), + description, + is_current, + 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, + })); + 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"); + + 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.model_family.get_model_slug() == preset.model; + 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, + })); + 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; + let current_sandbox = self.config.sandbox_policy.clone(); + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + for preset in presets.into_iter() { + let is_current = + Self::preset_matches_current(current_approval, ¤t_sandbox, &preset); + let name = 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(); + 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() + }); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + 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, + })); + 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, + 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)); + 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; + + 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(), + }); + })], + 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() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + + #[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 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) { + self.config.approval_policy = policy; + } + + /// Set the sandbox policy in the widget's config copy. + pub(crate) fn set_sandbox_policy(&mut self, policy: SandboxPolicy) { + #[cfg(target_os = "windows")] + let should_clear_downgrade = !matches!(policy, SandboxPolicy::ReadOnly) + || codex_core::get_platform_sandbox().is_some(); + + self.config.sandbox_policy = policy; + + #[cfg(target_os = "windows")] + if should_clear_downgrade { + self.config.forced_auto_mode_downgraded_on_windows = false; + } + } + + 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, model_family: ModelFamily) { + self.session_header.set_model(model); + self.model_family = model_family; + } + + 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); + } + } + + /// 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); + } + + /// Handle Ctrl-C key press. + fn on_ctrl_c(&mut self) { + if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled { + return; + } + + if self.bottom_pane.is_task_running() { + self.bottom_pane.show_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + return; + } + + self.submit_op(Op::Shutdown); + } + + 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); + } + + 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(); + } + /// 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); + } + + 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() + } + + /// 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 EXAMPLE_PROMPTS: [&str; 6] = [ + "Explain this codebase", + "Summarize recent commits", + "Implement {feature}", + "Find and fix a bug in @filename", + "Write tests for @filename", + "Improve documentation in @filename", +]; + +// 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).await { + 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() + }); +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/codex-rs/tui2/src/chatwidget/agent.rs b/codex-rs/tui2/src/chatwidget/agent.rs new file mode 100644 index 00000000000..240972347fb --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/agent.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::NewConversation; +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 NewConversation { + conversation_id: _, + conversation, + session_configured, + } = match server.new_conversation(config).await { + Ok(v) => v, + #[allow(clippy::print_stderr)] + Err(err) => { + let message = err.to_string(); + eprintln!("{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::ExitRequest); + 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 conversation_clone = conversation.clone(); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + let id = conversation_clone.submit(op).await; + if let Err(e) = id { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + while let Ok(event) = conversation.next_event().await { + app_event_tx_clone.send(AppEvent::CodexEvent(event)); + } + }); + + codex_op_tx +} + +/// Spawn agent loops for an existing conversation (e.g., a forked conversation). +/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent +/// events and accepts Ops for submission. +pub(crate) fn spawn_agent_from_existing( + conversation: 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 conversation_clone = conversation.clone(); + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + let id = conversation_clone.submit(op).await; + if let Err(e) = id { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + while let Ok(event) = conversation.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 new file mode 100644 index 00000000000..dc1e683ea55 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/interrupts.rs @@ -0,0 +1,96 @@ +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 new file mode 100644 index 00000000000..32e31b6682e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/session_header.rs @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000000..26c7f587096 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..c69730b4833 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec.snap @@ -0,0 +1,15 @@ +--- +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_no_reason.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap new file mode 100644 index 00000000000..ab469f34b6a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..a5bfd136b78 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approval_modal_patch.snap @@ -0,0 +1,17 @@ +--- +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. 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 new file mode 100644 index 00000000000..46ec74d1179 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..5024b90a62d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap @@ -0,0 +1,14 @@ +--- +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__chat_small_idle_h1.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap new file mode 100644 index 00000000000..8900e83d9af --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h1.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..a2afe14dfa0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h2.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..1b285fb8108 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_idle_h3.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..8900e83d9af --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h1.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..a2afe14dfa0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h2.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..1b285fb8108 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chat_small_running_h3.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..a447b748bb8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,17 @@ +--- +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 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 new file mode 100644 index 00000000000..9ab9b033808 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 00000000000..3cc0b593d4e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 00000000000..3d83bdb0f5a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..6d252a0d3e3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..50c08287731 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..d7e1e2ac3a0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..2d3767dffb6 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..93451be714d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exec_approval_modal_exec.snap @@ -0,0 +1,36 @@ +--- +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 new file mode 100644 index 00000000000..7a20304601e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..b13ce510e00 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..ab15a80ff37 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..21b41860fc9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..21b41860fc9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..a38d4c7fd22 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..52ce03bbea3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..21d031df6c8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..3d83bdb0f5a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..f7a2b3dcb66 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +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 new file mode 100644 index 00000000000..3863f9a8d50 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..943fe344402 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..31c5e74b0a3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__local_image_attachment_history_snapshot.snap @@ -0,0 +1,6 @@ +--- +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__model_reasoning_selection_popup.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap new file mode 100644 index 00000000000..cbf5f0fb526 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +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) Maximizes 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 new file mode 100644 index 00000000000..ed6c6fee19d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -0,0 +1,15 @@ +--- +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 Maximizes 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 new file mode 100644 index 00000000000..b0482e7ec9b --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__model_selection_popup.snap @@ -0,0 +1,16 @@ +--- +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.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. robin Robin + 5. 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_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 new file mode 100644 index 00000000000..d553957350e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..f761e5730bc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_active.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..567794cea6c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__status_widget_and_approval_modal.snap @@ -0,0 +1,17 @@ +--- +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 new file mode 100644 index 00000000000..3a9f08ab94a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__user_shell_ls_output.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..e139b510881 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..15511611a10 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -0,0 +1,17 @@ +--- +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 new file mode 100644 index 00000000000..2bbe9aefcdf --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..ed18675ac39 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -0,0 +1,17 @@ +--- +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. 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 new file mode 100644 index 00000000000..6758ec62c57 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..6758ec62c57 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -0,0 +1,13 @@ +--- +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 new file mode 100644 index 00000000000..77738439a17 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__binary_size_ideal_response.snap @@ -0,0 +1,153 @@ +--- +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 new file mode 100644 index 00000000000..1e73a237ebc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..7a04b0ef196 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..4487d0652e8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..1e73a237ebc --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..7a04b0ef196 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..4487d0652e8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..c3bdf60bd2c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -0,0 +1,17 @@ +--- +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 new file mode 100644 index 00000000000..1ed73b5fa5c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_markdown_code_blocks_vt100_snapshot.snap @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 00000000000..6d9aa515b1a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_tall.snap @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 00000000000..6062087181d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..e8f08a437ac --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..f04e1f078a8 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..d35cb175972 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_multiline.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: lines_to_single_string(&aborted_multi) +--- +✗ You canceled the request to run echo line1 ... + 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 new file mode 100644 index 00000000000..2f0f1412a1f --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..1c6a3ef1367 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -0,0 +1,36 @@ +--- +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 new file mode 100644 index 00000000000..588a9503eb3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step1_start_ls.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..492e8b7708c --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step2_finish_ls.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..2ce41709299 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step3_start_cat_foo.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..9e29785f715 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step4_finish_cat_foo.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..296b00f905d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step5_finish_sed_range.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..55fa9791234 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exploring_step6_finish_cat_bar.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..4a98242027e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_selection_popup.snap @@ -0,0 +1,11 @@ +--- +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 new file mode 100644 index 00000000000..cc3d8e37559 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..6062087181d --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000000..71dac5f5902 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +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 new file mode 100644 index 00000000000..59eff20acee --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_exec_marks_failed.snap @@ -0,0 +1,6 @@ +--- +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__interrupted_turn_error_message.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap new file mode 100644 index 00000000000..60715e581e0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap @@ -0,0 +1,5 @@ +--- +source: tui/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_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 new file mode 100644 index 00000000000..cf4c6943fd3 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap @@ -0,0 +1,6 @@ +--- +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 new file mode 100644 index 00000000000..b4b89736a96 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +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) Maximizes 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 new file mode 100644 index 00000000000..c5332ff5907 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup_extra_high_warning.snap @@ -0,0 +1,16 @@ +--- +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 Maximizes 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 new file mode 100644 index 00000000000..56a209ef73a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -0,0 +1,15 @@ +--- +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 new file mode 100644 index 00000000000..e210d1f0a39 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..9fbebfb500f --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap @@ -0,0 +1,12 @@ +--- +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 new file mode 100644 index 00000000000..5e6e33dece9 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -0,0 +1,17 @@ +--- +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 new file mode 100644 index 00000000000..6a49cb253c4 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap @@ -0,0 +1,14 @@ +--- +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 new file mode 100644 index 00000000000..c67cd637d7a --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui__chatwidget__tests__user_shell_ls_output.snap @@ -0,0 +1,7 @@ +--- +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 new file mode 100644 index 00000000000..d9e242674b0 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -0,0 +1,3329 @@ +use super::*; +use crate::app_event::AppEvent; +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::ConfigOverrides; +use codex_core::config::ConfigToml; +use codex_core::openai_models::models_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::Op; +use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::RateLimitWindow; +use codex_core::protocol::ReviewCodeLocation; +use codex_core::protocol::ReviewFinding; +use codex_core::protocol::ReviewLineRange; +use codex_core::protocol::ReviewOutputEvent; +use codex_core::protocol::ReviewRequest; +use codex_core::protocol::ReviewTarget; +use codex_core::protocol::StreamErrorEvent; +use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TokenCountEvent; +use codex_core::protocol::TokenUsage; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol::UndoCompletedEvent; +use codex_core::protocol::UndoStartedEvent; +use codex_core::protocol::ViewImageToolCallEvent; +use codex_core::protocol::WarningEvent; +use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; +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 crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +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); +} + +fn test_config() -> Config { + // Use base defaults to avoid depending on host state. + + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .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, + } +} + +#[test] +fn resumed_initial_messages_render_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let conversation_id = ConversationId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + 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, + }), + EventMsg::AgentMessage(AgentMessageEvent { + message: "assistant reply".to_string(), + }), + ]), + skill_load_outcome: None, + 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. +#[test] +fn entered_review_mode_uses_request_hint() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + 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. +#[test] +fn entered_review_mode_defaults_to_current_changes_banner() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + 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); +} + +/// Completing review with findings shows the selection popup and finishes with +/// the closing banner while clearing review mode state. +#[test] +fn exited_review_mode_emits_results_and_finishes() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + let review = ReviewOutputEvent { + findings: vec![ReviewFinding { + title: "[P1] Fix bug".to_string(), + body: "Something went wrong".to_string(), + confidence_score: 0.9, + priority: 1, + code_location: ReviewCodeLocation { + absolute_file_path: PathBuf::from("src/lib.rs"), + line_range: ReviewLineRange { start: 10, end: 12 }, + }, + }], + overall_correctness: "needs work".to_string(), + overall_explanation: "Investigate the failure".to_string(), + overall_confidence_score: 0.5, + }; + + chat.handle_codex_event(Event { + id: "review-end".into(), + msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { + review_output: Some(review), + }), + }); + + let cells = drain_insert_history(&mut rx); + let banner = lines_to_single_string(cells.last().expect("finished banner")); + assert_eq!(banner, "\n<< Code review finished >>\n"); + assert!(!chat.is_review_mode); +} + +/// Exiting review restores the pre-review context window indicator. +#[test] +fn review_restores_context_window_indicator() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None); + + 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. +#[test] +fn token_count_none_resets_context_indicator() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(None); + + 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); +} + +#[test] +fn context_indicator_shows_used_tokens_when_window_unknown() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")); + + 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(); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let model_family = ModelsManager::construct_model_family_offline(&resolved_model, &cfg); + let conversation_manager = Arc::new(ConversationManager::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, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: tx, + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: false, + auth_manager, + models_manager: conversation_manager.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + skills: None, + is_first_run: true, + model_family, + }; + let mut w = ChatWidget::new(init, conversation_manager); + // Basic construction sanity. + let _ = &mut w; +} + +// --- Helpers for tests that need direct construction and event draining --- +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(); + 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 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, + }); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let widget = ChatWidget { + app_event_tx, + codex_op_tx: op_tx, + bottom_pane: bottom, + active_cell: None, + config: cfg.clone(), + model_family: ModelsManager::construct_model_family_offline(&resolved_model, &cfg), + auth_manager: auth_manager.clone(), + models_manager: Arc::new(ModelsManager::new(auth_manager)), + session_header: SessionHeader::new(resolved_model.clone()), + 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, + mcp_startup_status: None, + 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, + frame_requester: FrameRequester::test_dummy(), + show_welcome_banner: true, + queued_user_messages: VecDeque::new(), + suppress_session_configured_redraw: false, + pending_notification: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), + current_rollout_path: None, + }; + (widget, rx, op_rx) +} + +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.auth_manager.clone())); +} + +pub(crate) 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); + 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), + } +} + +#[test] +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" + ); +} + +#[test] +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" + ); +} + +#[test] +fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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) + ); +} + +#[test] +fn rate_limit_snapshot_updates_and_retains_plan_type() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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)); +} + +#[test] +fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() { + let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)); + 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 + )); +} + +#[test] +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")); + 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 + )); +} + +#[test] +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")); + 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 + )); +} + +#[test] +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")); + 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 + )); +} + +#[test] +fn rate_limit_switch_prompt_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")); + 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) + +#[test] +fn exec_approval_emits_proposed_command_and_decision_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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) + ); +} + +#[test] +fn exec_approval_decision_truncates_multiline_and_long_commands() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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() + .expect("models lock available"); + models + .iter() + .find(|&preset| preset.model == model) + .cloned() + .unwrap_or_else(|| panic!("{model} preset not found")) +} + +#[test] +fn empty_enter_during_task_does_not_queue() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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()); +} + +#[test] +fn alt_up_edits_most_recent_queued_message() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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. +#[test] +fn enqueueing_history_prompt_multiple_times_is_stable() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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); + + 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::Enter, KeyModifiers::NONE)); + } + + assert_eq!(chat.queued_user_messages.len(), 3); + for message in chat.queued_user_messages.iter() { + assert_eq!(message.text, "repeat me"); + } +} + +#[test] +fn streaming_final_answer_keeps_task_running_state() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.on_task_started(); + 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::Enter, 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.ctrl_c_quit_hint_visible()); +} + +#[test] +fn ctrl_c_shutdown_ignores_caps_lock() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); + + match op_rx.try_recv() { + Ok(Op::Shutdown) => {} + other => panic!("expected Op::Shutdown, got {other:?}"), + } +} + +#[test] +fn ctrl_c_cleared_prompt_is_recoverable_via_history() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None); + + chat.bottom_pane.insert_str("draft message "); + chat.bottom_pane + .attach_image(PathBuf::from("/tmp/preview.png"), 24, 42, "png"); + let placeholder = "[preview.png 24x42]"; + 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.ctrl_c_quit_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.ctrl_c_quit_hint_visible()); + + let images = chat.bottom_pane.take_recent_submission_images(); + assert!( + images.is_empty(), + "attachments are not preserved in history recall" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_completed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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:?}" + ); +} + +#[test] +fn exec_history_cell_shows_working_then_failed() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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"); +} + +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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:?}" + ); +} + +#[test] +fn exec_history_shows_unified_exec_startup_commands() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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. +#[test] +fn review_popup_custom_prompt_action_sends_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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"); +} + +#[test] +fn slash_init_skips_when_project_doc_exists() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + 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 slash_quit_requests_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Quit); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); +} + +#[test] +fn slash_exit_requests_exit() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Exit); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); +} + +#[test] +fn slash_resume_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Resume); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); +} + +#[test] +fn slash_undo_sends_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + chat.dispatch_command(SlashCommand::Undo); + + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::Undo)) => {} + other => panic!("expected AppEvent::CodexOp(Op::Undo), got {other:?}"), + } +} + +#[test] +fn slash_rollout_displays_current_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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}" + ); +} + +#[test] +fn slash_rollout_handles_missing_path() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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}" + ); +} + +#[test] +fn undo_success_events_render_info_messages() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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:?}" + ); +} + +#[test] +fn undo_failure_events_render_error_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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:?}" + ); +} + +#[test] +fn undo_started_hides_interrupt_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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). +#[test] +fn review_commit_picker_shows_subjects_without_timestamps() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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. +#[test] +fn custom_prompt_submit_sends_review_op() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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. +#[test] +fn custom_prompt_enter_empty_does_not_send() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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"); +} + +#[test] +fn view_image_tool_call_adds_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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. +#[test] +fn interrupt_exec_marks_failed_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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. +#[test] +fn interrupted_turn_error_message_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate an in-progress task so the widget is in a running state. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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). +#[test] +fn review_custom_prompt_escape_navigates_back_then_dismisses() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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); + + // 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") +} + +#[test] +fn model_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5-codex")); + chat.open_model_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("model_selection_popup", popup); +} + +#[test] +fn approvals_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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); +} + +#[test] +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![PathBuf::from("C:\\extra")], + 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" + ); +} + +#[test] +fn full_access_confirmation_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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")] +#[test] +fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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("Agent mode on Windows uses an experimental sandbox"), + "expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}" + ); +} + +#[cfg(target_os = "windows")] +#[test] +fn startup_prompts_for_windows_sandbox_when_agent_requested() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + 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("Agent mode on Windows uses an experimental sandbox"), + "expected startup prompt to explain sandbox: {popup}" + ); + assert!( + popup.contains("Enable experimental sandbox"), + "expected startup prompt to offer enabling the sandbox: {popup}" + ); + + set_windows_sandbox_enabled(true); +} + +#[test] +fn model_reasoning_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + 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); +} + +#[test] +fn model_reasoning_selection_popup_extra_high_warning_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + 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); +} + +#[test] +fn reasoning_popup_shows_extra_high_with_space() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")); + + 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}" + ); +} + +#[test] +fn single_reasoning_option_skips_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + let single_effort = vec![ReasoningEffortPreset { + effort: ReasoningEffortConfig::High, + description: "Maximizes 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, + }; + 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:?}" + ); +} + +#[test] +fn feedback_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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); +} + +#[test] +fn feedback_upload_consent_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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); +} + +#[test] +fn reasoning_popup_escape_returns_to_model_popup() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")); + chat.open_model_popup(); + + let preset = get_available_model(&chat, "gpt-5.1-codex"); + 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")); +} + +#[test] +fn exec_history_extends_previous_when_consecutive() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + + // 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)); +} + +#[test] +fn user_shell_command_renders_output_not_exploring() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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); +} + +#[test] +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); + 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. +#[test] +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); + // Ensure policy allows surfacing approvals explicitly (not strictly required for direct event). + chat.config.approval_policy = 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. +#[test] +fn approval_modal_exec_without_reason_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.config.approval_policy = 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: patch approval modal +#[test] +fn approval_modal_patch_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.config.approval_policy = 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() + ); +} + +#[test] +fn interrupt_restores_queued_messages_into_composer() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + // 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); +} + +#[test] +fn interrupt_prepends_queued_messages_before_existing_composer_text() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + 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. +#[test] +fn ui_snapshots_small_heights_idle() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (chat, _rx, _op_rx) = make_chatwidget_manual(None); + 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. +#[test] +fn ui_snapshots_small_heights_task_running() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Activate status line + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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. +#[test] +fn status_widget_and_approval_modal_snapshot() { + use codex_core::protocol::ExecApprovalRequestEvent; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Begin a running task so the status indicator would be active. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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. +#[test] +fn status_widget_active_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Activate the status indicator by simulating a task start. + chat.handle_codex_event(Event { + id: "task-1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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()); +} + +#[test] +fn background_event_updates_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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()); +} + +#[test] +fn apply_patch_events_emit_history_cells() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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" + ); +} + +#[test] +fn apply_patch_manual_approval_adjusts_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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:?}" + ); +} + +#[test] +fn apply_patch_manual_flow_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + 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) + ); +} + +#[test] +fn apply_patch_approval_sends_op_with_submission_id() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + // 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"); +} + +#[test] +fn apply_patch_full_flow_integration_like() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None); + + // 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, + }), + }); +} + +#[test] +fn apply_patch_untrusted_shows_approval_modal() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + // Ensure approval policy is untrusted (OnRequest) + chat.config.approval_policy = 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?'" + ); +} + +#[test] +fn apply_patch_request_shows_diff_summary() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Ensure we are in OnRequest so an approval is surfaced + chat.config.approval_policy = 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" + ); +} + +#[test] +fn plan_update_renders_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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")); +} + +#[test] +fn stream_error_updates_status_indicator() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.bottom_pane.set_task_running(true); + let msg = "Reconnecting... 2/5"; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: msg.to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }); + + 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); +} + +#[test] +fn warning_event_adds_warning_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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}" + ); +} + +#[test] +fn stream_recovery_restores_previous_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "task".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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), + }), + }); + 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!(chat.retry_status_header.is_none()); +} + +#[test] +fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Begin turn + chat.handle_codex_event(Event { + id: "s1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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::TaskComplete(TaskCompleteEvent { + 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}"); +} + +#[test] +fn final_reasoning_then_message_without_deltas_are_rendered() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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); +} + +#[test] +fn deltas_then_same_final_message_are_rendered_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // 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. +#[test] +fn chatwidget_exec_and_status_layout_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + 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::TaskStarted(TaskStartedEvent { + 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 +#[test] +fn chatwidget_markdown_code_blocks_vt100_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + + // Simulate a final agent message via streaming deltas instead of a single message + + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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::TaskComplete(TaskCompleteEvent { + 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()); +} + +#[test] +fn chatwidget_tall() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None); + chat.handle_codex_event(Event { + id: "t1".into(), + msg: EventMsg::TaskStarted(TaskStartedEvent { + 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 new file mode 100644 index 00000000000..b0daa447701 --- /dev/null +++ b/codex-rs/tui2/src/cli.rs @@ -0,0 +1,115 @@ +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, + + /// 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 or ollama). + /// 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 web search (off by default). 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, + + #[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, + 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, + config_overrides: cli.config_overrides, + } + } +} diff --git a/codex-rs/tui2/src/clipboard_paste.rs b/codex-rs/tui2/src/clipboard_paste.rs new file mode 100644 index 00000000000..5863c728b09 --- /dev/null +++ b/codex-rs/tui2/src/clipboard_paste.rs @@ -0,0 +1,504 @@ +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(); + + // file:// URL → filesystem path + if let Ok(url) = url::Url::parse(pasted) + && 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). + let looks_like_windows_path = { + // Drive letter path: C:\ or C:/ + let drive = pasted + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + && pasted.get(1..2) == Some(":") + && pasted + .get(2..3) + .map(|s| s == "\\" || s == "/") + .unwrap_or(false); + // UNC path: \\server\share + let unc = pasted.starts_with("\\\\"); + drive || unc + }; + if looks_like_windows_path { + #[cfg(target_os = "linux")] + { + if is_probably_wsl() + && let Some(converted) = convert_windows_path_to_wsl(pasted) + { + return Some(converted); + } + } + return Some(PathBuf::from(pasted)); + } + + // shell-escaped single path → unescaped + let parts: Vec = shlex::Shlex::new(pasted).collect(); + if parts.len() == 1 { + return parts.into_iter().next().map(PathBuf::from); + } + + 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) +} + +/// 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 result = + normalize_pasted_path(input).expect("should trim single quotes on windows path"); + assert_eq!(result, PathBuf::from(r"C:\\Users\\Alice\\My File.jpeg")); + } + + #[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/color.rs b/codex-rs/tui2/src/color.rs new file mode 100644 index 00000000000..f5121a1f6c6 --- /dev/null +++ b/codex-rs/tui2/src/color.rs @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000000..46d16a83f05 --- /dev/null +++ b/codex-rs/tui2/src/custom_terminal.rs @@ -0,0 +1,645 @@ +// 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 new file mode 100644 index 00000000000..24c5be597b7 --- /dev/null +++ b/codex-rs/tui2/src/diff_render.rs @@ -0,0 +1,673 @@ +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; + } + } + } + } + } + } + } +} + +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { + let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { + (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, + _ => false, + }; + let chosen = if path_in_same_repo { + pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) + } else { + relativize_to_home(path) + .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 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 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() { + // Write a temporary file so the delete renderer can read original content + let tmp_path = PathBuf::from("tmp_delete_example.txt"); + std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file"); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + tmp_path.clone(), + FileChange::Delete { + content: "first\nsecond\nthird\n".to_string(), + }, + ); + + let lines = diff_summary_for_tests(&changes); + + // Cleanup best-effort; rendering has already read the file + let _ = std::fs::remove_file(&tmp_path); + + snapshot_lines("apply_delete_block", lines, 80, 12); + } + + #[test] + fn ui_snapshot_apply_update_block_wraps_long_lines() { + // Create a patch with a long modified line to force wrapping + let original = "line 1\nshort\nline 3\n"; + let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = 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 new file mode 100644 index 00000000000..906091113e9 --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/mod.rs @@ -0,0 +1,12 @@ +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 new file mode 100644 index 00000000000..76316968c6d --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/model.rs @@ -0,0 +1,150 @@ +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(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 new file mode 100644 index 00000000000..6517bcf470a --- /dev/null +++ b/codex-rs/tui2/src/exec_cell/render.rs @@ -0,0 +1,705 @@ +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 new file mode 100644 index 00000000000..8ce6c2632e4 --- /dev/null +++ b/codex-rs/tui2/src/exec_command.rs @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000000..af465126400 --- /dev/null +++ b/codex-rs/tui2/src/file_search.rs @@ -0,0 +1,199 @@ +//! 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 new file mode 100644 index 00000000000..19a70578d48 --- /dev/null +++ b/codex-rs/tui2/src/frames.rs @@ -0,0 +1,71 @@ +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 new file mode 100644 index 00000000000..78ab53d92f6 --- /dev/null +++ b/codex-rs/tui2/src/get_git_diff.rs @@ -0,0 +1,119 @@ +//! 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 new file mode 100644 index 00000000000..41470673668 --- /dev/null +++ b/codex-rs/tui2/src/history_cell.rs @@ -0,0 +1,2435 @@ +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::line_utils::push_owned_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 crate::wrapping::word_wrap_lines; +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::config::types::ReasoningSummaryFormat; +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; + +/// 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) + } + + 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 + } +} + +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> { + let mut lines: Vec> = Vec::new(); + + 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 = 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), + ); + + lines.push(Line::from("").style(style)); + lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); + lines.push(Line::from("").style(style)); + lines + } +} + +#[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> { + 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::>(); + + word_wrap_lines( + &summary_lines, + RtOptions::new(width as usize) + .initial_indent("• ".dim().into()) + .subsequent_indent(" ".into()), + ) + } +} + +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 desired_transcript_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +#[derive(Debug)] +pub(crate) struct AgentMessageCell { + lines: Vec>, + is_first_line: bool, +} + +impl AgentMessageCell { + pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines, + is_first_line, + } + } +} + +impl HistoryCell for AgentMessageCell { + fn display_lines(&self, width: u16) -> Vec> { + word_wrap_lines( + &self.lines, + RtOptions::new(width as usize) + .initial_indent(if self.is_first_line { + "• ".dim().into() + } else { + " ".into() + }) + .subsequent_indent(" ".into()), + ) + } + + 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> { + if width == 0 { + return Vec::new(); + } + let opts = RtOptions::new(width.max(1) as usize) + .initial_indent(self.initial_prefix.clone()) + .subsequent_indent(self.subsequent_prefix.clone()); + let wrapped = word_wrap_lines(&self.text, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out + } + + fn desired_height(&self, width: u16) -> u16 { + self.display_lines(width).len() as u16 + } +} + +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: &'static str, +} + +impl TooltipHistoryCell { + fn new(tip: &'static str) -> 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(), + 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)] +struct SessionHeaderHistoryCell { + version: &'static str, + model: String, + reasoning_effort: Option, + directory: PathBuf, +} + +impl SessionHeaderHistoryCell { + fn new( + model: String, + reasoning_effort: Option, + directory: PathBuf, + version: &'static str, + ) -> Self { + Self { + version, + model, + 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::from(self.model.clone()), + ]; + 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(_) => "