diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 59a1e07fa7..1a0a69035f 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -107,6 +107,45 @@ jobs: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash run: | + set -o pipefail + + bazel_console_log="$(mktemp)" + + print_failed_bazel_test_logs() { + local console_log="$1" + local testlogs_dir + + testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" + + local failed_targets=() + while IFS= read -r target; do + failed_targets+=("$target") + done < <( + grep -E '^FAIL: //' "$console_log" \ + | sed -E 's#^FAIL: (//[^ ]+).*#\1#' \ + | sort -u + ) + + if [[ ${#failed_targets[@]} -eq 0 ]]; then + echo "No failed Bazel test targets were found in console output." + return + fi + + for target in "${failed_targets[@]}"; do + local rel_path="${target#//}" + rel_path="${rel_path/:/\/}" + local test_log="${testlogs_dir}/${rel_path}/test.log" + + echo "::group::Bazel test log tail for ${target}" + if [[ -f "$test_log" ]]; then + tail -n 200 "$test_log" + else + echo "Missing test log: $test_log" + fi + echo "::endgroup::" + done + } + bazel_args=( test //... @@ -119,10 +158,19 @@ jobs: if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then echo "BuildBuddy API key is available; using remote Bazel configuration." + # Work around Bazel 9 remote repo contents cache / overlay materialization failures + # seen in CI (for example "is not a symlink" or permission errors while + # materializing external repos such as rules_perl). We still use BuildBuddy for + # remote execution/cache; this only disables the startup-level repo contents cache. + set +e bazel $BAZEL_STARTUP_ARGS \ + --noexperimental_remote_repo_contents_cache \ --bazelrc=.github/workflows/ci.bazelrc \ "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" + "--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e else echo "BuildBuddy API key is not available; using local Bazel configuration." # Keep fork/community PRs on Bazel but disable remote services that are @@ -141,9 +189,18 @@ jobs: # clear remote cache/execution endpoints configured in .bazelrc. # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor + set +e bazel $BAZEL_STARTUP_ARGS \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ --remote_cache= \ - --remote_executor= + --remote_executor= \ + 2>&1 | tee "$bazel_console_log" + bazel_status=${PIPESTATUS[0]} + set -e + fi + + if [[ ${bazel_status:-0} -ne 0 ]]; then + print_failed_bazel_test_logs "$bazel_console_log" + exit "$bazel_status" fi diff --git a/BUILD.bazel b/BUILD.bazel index 095b7a5667..66548cd03c 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,4 +1,11 @@ load("@apple_support//xcode:xcode_config.bzl", "xcode_config") +load("@rules_cc//cc:defs.bzl", "cc_shared_library") + +cc_shared_library( + name = "clang", + deps = ["@llvm-project//clang:libclang"], + visibility = ["//visibility:public"], +) xcode_config(name = "disable_xcode") diff --git a/MODULE.bazel b/MODULE.bazel index 821e41878f..b2115f4664 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,5 +1,7 @@ +module(name = "codex") + bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.3") +bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.6") single_version_override( module_name = "toolchains_llvm_bootstrapped", patch_strip = 1, @@ -8,6 +10,8 @@ single_version_override( ], ) +register_toolchains("@toolchains_llvm_bootstrapped//toolchain:all") + osx = use_extension("@toolchains_llvm_bootstrapped//extensions:osx.bzl", "osx") osx.framework(name = "ApplicationServices") osx.framework(name = "AppKit") @@ -16,8 +20,12 @@ osx.framework(name = "CoreFoundation") osx.framework(name = "CoreGraphics") osx.framework(name = "CoreServices") osx.framework(name = "CoreText") +osx.framework(name = "AudioToolbox") osx.framework(name = "CFNetwork") osx.framework(name = "FontServices") +osx.framework(name = "AudioUnit") +osx.framework(name = "CoreAudio") +osx.framework(name = "CoreAudioTypes") osx.framework(name = "Foundation") osx.framework(name = "ImageIO") osx.framework(name = "IOKit") @@ -25,10 +33,7 @@ osx.framework(name = "Kernel") osx.framework(name = "OSLog") osx.framework(name = "Security") osx.framework(name = "SystemConfiguration") - -register_toolchains( - "@toolchains_llvm_bootstrapped//toolchain:all", -) +use_repo(osx, "macosx15.4.sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") @@ -39,9 +44,9 @@ bazel_dep(name = "rules_rs", version = "0.0.23") # Special toolchains branch archive_override( module_name = "rules_rs", - integrity = "sha256-YbDRjZos4UmfIPY98znK1BgBWRQ1/ui3CtL6RqxE30I=", - strip_prefix = "rules_rs-6cf3d940fdc48baf3ebd6c37daf8e0be8fc73ecb", - url = "https://github.com/dzbarsky/rules_rs/archive/6cf3d940fdc48baf3ebd6c37daf8e0be8fc73ecb.tar.gz", + integrity = "sha256-O34UF4H7b1Qacu3vlu2Od4ILGVApzg5j1zl952SFL3w=", + strip_prefix = "rules_rs-097123c2aa72672e371e69e7035869f5a45c7b2b", + url = "https://github.com/dzbarsky/rules_rs/archive/097123c2aa72672e371e69e7035869f5a45c7b2b.tar.gz", ) rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust") @@ -134,6 +139,9 @@ crate.annotation( "OPENSSL_NO_VENDOR": "1", "OPENSSL_STATIC": "1", }, + crate_features = [ + "dep:openssl-src", + ], crate = "openssl-sys", data = ["@openssl//:gen_dir"], ) @@ -145,6 +153,28 @@ crate.annotation( workspace_cargo_toml = "rust/runfiles/Cargo.toml", ) +llvm = use_extension("@toolchains_llvm_bootstrapped//extensions:llvm.bzl", "llvm") +use_repo(llvm, "llvm-project") + +crate.annotation( + # Provide the hermetic SDK path so the build script doesn't try to invoke an unhermetic `xcrun --show-sdk-path`. + build_script_data = [ + "@macosx15.4.sdk//sysroot", + ], + build_script_env = { + "BINDGEN_EXTRA_CLANG_ARGS": "-isystem $(location @toolchains_llvm_bootstrapped//:builtin_headers)", + "COREAUDIO_SDK_PATH": "$(location @macosx15.4.sdk//sysroot)", + "LIBCLANG_PATH": "$(location @codex//:clang)", + }, + build_script_tools = [ + "@codex//:clang", + "@toolchains_llvm_bootstrapped//:builtin_headers", + ], + crate = "coreaudio-sys", +) + +inject_repo(crate, "codex", "toolchains_llvm_bootstrapped", "macosx15.4.sdk") + # Fix readme inclusions crate.annotation( crate = "windows-link", @@ -175,6 +205,17 @@ crate.annotation( gen_build_script = "off", deps = [":windows_import_lib"], ) + +bazel_dep(name = "alsa_lib", version = "1.2.9.bcr.4") + +crate.annotation( + crate = "alsa-sys", + gen_build_script = "off", + deps = ["@alsa_lib"], +) + +inject_repo(crate, "alsa_lib") + use_repo(crate, "crates") rbe_platform_repository = use_repo_rule("//:rbe.bzl", "rbe_platform_repository") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 812ab2670c..c31aef3334 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -14,6 +14,8 @@ "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/alsa_lib/1.2.9.bcr.4/MODULE.bazel": "66842efc2b50b7c12274a5218d468119a5d6f9dc46a5164d9496fb517f64aba6", + "https://bcr.bazel.build/modules/alsa_lib/1.2.9.bcr.4/source.json": "118ea7208f2fab61b4b14cc763a6fb4e5931bc4d3cec30b83c749a3134463752", "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", @@ -49,7 +51,8 @@ "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d", - "https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751", + "https://bcr.bazel.build/modules/bazel_lib/3.2.0/MODULE.bazel": "39b50d94b9be6bda507862254e20c263f9b950e3160112348d10a938be9ce2c2", + "https://bcr.bazel.build/modules/bazel_lib/3.2.0/source.json": "a6f45a903134bebbf33a6166dd42b4c7ab45169de094b37a85f348ca41170a84", "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", @@ -221,8 +224,8 @@ "https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c", "https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658", "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.2/MODULE.bazel": "f7c822cea99caef928d7cbe695498096e53c4b2c0ea45997e9a64bf6b77b43b0", - "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.3/MODULE.bazel": "902ab5c2bc9eff04df45d17aac3c5c642afaad2d4569dcfdc86d0256f68f18e0", - "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.3/source.json": "8c03ccd25188ecf8baf4a97a8b42c507ff592d917bcbc06333b9c82d18c7ef13", + "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.6/MODULE.bazel": "7298112608aefec21ea8bfbe325b14472222a0243c3dabcd0287eba418791f35", + "https://bcr.bazel.build/modules/toolchains_llvm_bootstrapped/0.5.6/source.json": "c2a30627cfe15e4deeba63a75d1894324444ae0276eb17ac0161ba5c220c1cb2", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", @@ -1529,16 +1532,16 @@ }, "@@rules_rs+//rs/experimental/toolchains:module_extension.bzl%toolchains": { "cargo-1.93.0-aarch64-apple-darwin.tar.xz": "6443909350322ad07f09bb5edfd9ff29268e6fe88c7d78bfba7a5e254248dc25", - "cargo-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "387832b989c8eb96c9ebd66402a87962167633bc6d91a49ffb8c7903c45f3476", + "cargo-1.93.0-aarch64-pc-windows-msvc.tar.xz": "155bff7a16aa7054e7ed7c3a82e362d4b302b3882d751b823e06ff63ae3f103d", "cargo-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "5998940b8b97286bb67facb1a85535eeb3d4d7a61e36a85e386e5c0c5cfe5266", "cargo-1.93.0-x86_64-apple-darwin.tar.xz": "95a47c5ed797c35419908f04188d8b7de09946e71073c4b72632b16f5b10dfae", - "cargo-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "f19766837559f90476508140cb95cc708220012ec00a854fa9f99187b1f246b6", + "cargo-1.93.0-x86_64-pc-windows-msvc.tar.xz": "e59c5e2baa9ec17261f2cda6676ebf7b68b21a860e3f7451c4d964728951da75", "cargo-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "c23de3ae709ff33eed5e4ae59d1f9bcd75fa4dbaa9fb92f7b06bfb534b8db880", "clippy-1.93.0-aarch64-apple-darwin.tar.xz": "0b6e943a8d12be0e68575acf59c9ea102daf795055fcbbf862b0bfd35ec40039", - "clippy-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "296949f49be2de77ce9d1c5023a5f0a58e28e329eec03642a3da0e175a67beeb", + "clippy-1.93.0-aarch64-pc-windows-msvc.tar.xz": "07bcf2edb88cdf5ead2f02e4a8493e9b0ef935a31253fac6f9f3378d8023f113", "clippy-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "872ae6d68d625946d281b91d928332e6b74f6ab269b6af842338df4338805a60", "clippy-1.93.0-x86_64-apple-darwin.tar.xz": "e6d0b1afb9607c14a1172d09ee194a032bbb3e48af913d55c5a473e0559eddde", - "clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f", + "clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57", "clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d", "rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b", "rust-std-1.93.0-aarch64-apple-ios-macabi.tar.xz": "24d47e615ce101869ff452a572a6b77ed7cf70f2454d0b50892ac849e8c7ac4d", @@ -1606,16 +1609,16 @@ "rust-std-1.93.0-x86_64-unknown-none.tar.xz": "01dcca7ae4b7e82fbfa399adb5e160afaa13143e5a17e1e0737c38cf07365fb3", "rust-std-1.93.0-x86_64-unknown-uefi.tar.xz": "ec4e439d9485ce752b56999e8e41ed82373fc833a005cf2531c6f7ef7e785392", "rustc-1.93.0-aarch64-apple-darwin.tar.xz": "092be03c02b44c405dab1232541c84f32b2d9e8295747568c3d531dd137221dc", - "rustc-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "d3bc0cdaf157e20b1f23e510b5e3c4c6e9117d08f5284c04dee60aecff1bc851", + "rustc-1.93.0-aarch64-pc-windows-msvc.tar.xz": "a3ac1a8e411de8470f71b366f89d187718c431526912b181692ed0a18c56c7ad", "rustc-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "1a9045695892ec08d8e9751bf7cf7db71fe27a6202dd12ce13aca48d0602dbde", "rustc-1.93.0-x86_64-apple-darwin.tar.xz": "594bb293f0a4f444656cf8dec2149fcb979c606260efee9e09bcf8c9c6ed6ae7", - "rustc-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "0cdaa8de66f5ce21d1ea73917efc5c64f408bda49f678ddde19465ced9d5ec63", + "rustc-1.93.0-x86_64-pc-windows-msvc.tar.xz": "fa17677eee0d83eb055b309953184bf87ba634923d8897f860cda65d55c6e350", "rustc-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "00c6e6740ea6a795e33568cd7514855d58408a1180cd820284a7bbf7c46af715", "rustfmt-1.93.0-aarch64-apple-darwin.tar.xz": "0dd1faedf0768ef362f4aae4424b34e8266f2b9cf5e76ea4fcaf780220b363a0", - "rustfmt-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "5888827e7fbd7d59930870b4856fce8d6d8fca5e02f6535f8ae3d7ad0ccf2d4a", + "rustfmt-1.93.0-aarch64-pc-windows-msvc.tar.xz": "24eed108489567133bbfe40c8eacda1567be55fae4c526911b39eb33eb27a6cb", "rustfmt-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "92e1acb45ae642136258b4dabb39302af2d53c83e56ebd5858bc969f9e5c141a", "rustfmt-1.93.0-x86_64-apple-darwin.tar.xz": "c8453b4c5758eb39423042ffa9c23ed6128cbed2b15b581e5e1192c9cc0b1d4e", - "rustfmt-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "47167e9e78db9be4503a060dee02f4df2cda252da32175dbf44331f965a747b9", + "rustfmt-1.93.0-x86_64-pc-windows-msvc.tar.xz": "5becc7c2dba4b9ab5199012cad30829235a7f7fb5d85a238697e8f0e44cbd9af", "rustfmt-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "7f81f6c17d11a7fda5b4e1b111942fb3b23d30dcec767e13e340ebfb762a5e33" } } diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 32ce724294..64a0ff783e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -18,7 +18,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -31,15 +31,15 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.11.2" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +checksum = "f860ee6746d0c5b682147b2f7f8ef036d4f92fe518251a3a35ffa3650eafdf0e" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "bytestring", "derive_more 2.1.1", @@ -62,9 +62,9 @@ dependencies = [ [[package]] name = "actix-router" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" dependencies = [ "bytestring", "cfg-if", @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.12.1" +version = "4.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" dependencies = [ "actix-codec", "actix-http", @@ -282,7 +282,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -291,6 +291,28 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -374,9 +396,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app_test_support" @@ -482,7 +504,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -494,7 +516,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -554,9 +576,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -590,7 +612,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -621,7 +643,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -632,7 +654,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -647,7 +669,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -672,7 +694,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -689,7 +711,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -725,9 +747,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -861,6 +883,24 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -884,9 +924,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ "serde_core", ] @@ -967,9 +1007,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" @@ -1047,7 +1087,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1082,9 +1122,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -1098,6 +1138,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1153,9 +1202,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1183,10 +1232,21 @@ dependencies = [ ] [[package]] -name = "clap" -version = "4.5.58" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -1194,9 +1254,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.58" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -1207,9 +1267,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.5.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031" dependencies = [ "clap", ] @@ -1223,7 +1283,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1338,7 +1398,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "tokio-util", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", "uuid", @@ -1503,7 +1563,7 @@ dependencies = [ "supports-color 3.0.2", "tempfile", "tokio", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "tracing", ] @@ -1549,7 +1609,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "tracing", ] @@ -1615,8 +1675,8 @@ dependencies = [ "sha2", "thiserror 2.0.18", "tokio", - "toml 0.9.11+spec-1.1.0", - "toml_edit 0.24.0+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.24.1+spec-1.1.0", "tracing", ] @@ -1709,8 +1769,8 @@ dependencies = [ "tokio", "tokio-tungstenite", "tokio-util", - "toml 0.9.11+spec-1.1.0", - "toml_edit 0.24.0+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.24.1+spec-1.1.0", "tracing", "tracing-subscriber", "tracing-test", @@ -1786,20 +1846,19 @@ dependencies = [ "codex-execpolicy", "codex-protocol", "codex-shell-command", + "codex-shell-escalation", "codex-utils-cargo-bin", + "core_test_support", "exec_server_test_support", - "libc", "maplit", - "path-absolutize", "pretty_assertions", "rmcp", + "schemars 1.2.1", "serde", "serde_json", "shlex", - "socket2 0.6.2", "tempfile", "tokio", - "tokio-util", "tracing", "tracing-subscriber", ] @@ -1846,7 +1905,7 @@ version = "0.0.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2201,6 +2260,27 @@ dependencies = [ "which", ] +[[package]] +name = "codex-shell-escalation" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "codex-core", + "codex-execpolicy", + "codex-protocol", + "libc", + "path-absolutize", + "pretty_assertions", + "serde", + "serde_json", + "socket2 0.6.2", + "tempfile", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "codex-skills" version = "0.0.0" @@ -2281,11 +2361,13 @@ dependencies = [ "codex-utils-sleep-inhibitor", "codex-windows-sandbox", "color-eyre", + "cpal", "crossterm", "derive_more 2.1.1", "diffy", "dirs", "dunce", + "hound", "image", "insta", "itertools 0.14.0", @@ -2314,7 +2396,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -2377,7 +2459,7 @@ dependencies = [ "codex-protocol", "pretty_assertions", "serde", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -2415,7 +2497,7 @@ version = "0.0.0" dependencies = [ "pretty_assertions", "serde_json", - "toml 0.9.11+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", ] [[package]] @@ -2484,6 +2566,7 @@ name = "codex-utils-string" version = "0.0.0" dependencies = [ "pretty_assertions", + "regex-lite", ] [[package]] @@ -2716,6 +2799,49 @@ dependencies = [ "zstd", ] +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2803,7 +2929,7 @@ name = "crossterm" version = "0.28.1" source = "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "futures-core", "mio", @@ -2909,7 +3035,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2953,7 +3079,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2967,7 +3093,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2980,7 +3106,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2991,7 +3117,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3002,7 +3128,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3013,9 +3139,15 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + [[package]] name = "data-encoding" version = "2.10.0" @@ -3123,9 +3255,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -3150,7 +3282,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3180,7 +3312,7 @@ dependencies = [ "convert_case 0.6.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -3194,7 +3326,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-xid", ] @@ -3285,7 +3417,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", ] @@ -3307,7 +3439,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3360,7 +3492,7 @@ checksum = "83e195b4945e88836d826124af44fdcb262ec01ef94d44f14f4fb5103f19892a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3380,9 +3512,9 @@ dependencies = [ [[package]] name = "ena" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" dependencies = [ "log", ] @@ -3429,7 +3561,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3450,7 +3582,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3611,7 +3743,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3621,7 +3753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.59.0", ] @@ -3703,9 +3835,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "libz-sys", @@ -3847,9 +3979,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -3862,9 +3994,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -3872,15 +4004,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -3900,9 +4032,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -3919,32 +4051,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -3954,7 +4086,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -3998,7 +4129,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link", ] @@ -4038,6 +4169,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -4054,6 +4198,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.18" @@ -4064,7 +4214,7 @@ dependencies = [ "bstr", "log", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax 0.8.9", ] [[package]] @@ -4270,6 +4420,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "http" version = "0.2.12" @@ -4370,7 +4526,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] @@ -4404,14 +4560,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http 1.4.0", "http-body", @@ -4477,7 +4632,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", "unic-langid", ] @@ -4491,7 +4646,7 @@ dependencies = [ "i18n-config", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4644,6 +4799,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -4785,7 +4946,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "inotify-sys", "libc", ] @@ -4831,7 +4992,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4855,9 +5016,9 @@ dependencies = [ [[package]] name = "inventory" -version = "0.3.21" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" dependencies = [ "rustversion", ] @@ -4954,9 +5115,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940" dependencies = [ "jiff-static", "log", @@ -4967,13 +5128,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -5010,9 +5171,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" dependencies = [ "once_cell", "wasm-bindgen", @@ -5030,7 +5191,7 @@ dependencies = [ "log", "secret-service", "security-framework 2.11.1", - "security-framework 3.5.1", + "security-framework 3.7.0", "windows-sys 0.60.2", "zbus", "zeroize", @@ -5114,10 +5275,16 @@ dependencies = [ ] [[package]] -name = "libc" -version = "0.2.180" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libdbus-sys" @@ -5128,6 +5295,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -5140,9 +5317,9 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.7.0", + "redox_syscall 0.7.1", ] [[package]] @@ -5179,7 +5356,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", ] @@ -5191,9 +5368,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -5319,6 +5496,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "maplit" version = "1.0.2" @@ -5383,9 +5569,9 @@ checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memoffset" @@ -5487,27 +5673,50 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -5529,7 +5738,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -5541,7 +5750,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -5554,7 +5763,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -5591,7 +5800,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fsevent-sys", "inotify", "kqueue", @@ -5609,7 +5818,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -5695,6 +5904,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -5746,6 +5966,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -5790,7 +6032,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -5802,7 +6044,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-foundation", ] @@ -5823,7 +6065,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -5834,7 +6076,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -5867,7 +6109,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -5885,7 +6127,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -5898,7 +6140,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", ] @@ -5909,7 +6151,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -5921,7 +6163,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "objc2", "objc2-cloud-kit", @@ -5955,6 +6197,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -5986,7 +6251,7 @@ version = "6.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "once_cell", "onig_sys", @@ -6014,7 +6279,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -6031,15 +6296,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -6211,9 +6470,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" dependencies = [ "supports-color 2.1.0", "supports-color 3.0.2", @@ -6366,7 +6625,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6434,11 +6693,11 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -6455,7 +6714,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6540,9 +6799,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", @@ -6554,15 +6813,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -6578,6 +6837,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -6606,7 +6875,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -6658,16 +6927,16 @@ dependencies = [ [[package]] name = "proptest" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.8", + "regex-syntax 0.8.9", "unarray", ] @@ -6691,14 +6960,14 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "psl" -version = "2.1.184" +version = "2.1.193" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dc6a90669f481b41cae3005c68efa36bef275b95aa9123a7af7f1c68c6e5b2" +checksum = "530defb7f90261f0f83c9af4534efe2e2e7912b265c7890fd402e55f60791fd4" dependencies = [ "psl-types", ] @@ -6715,7 +6984,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -6894,7 +7163,7 @@ checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" dependencies = [ "ahash", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "chrono", "const_format", "csv", @@ -7027,7 +7296,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7106,7 +7375,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", "x509-parser", ] @@ -7225,7 +7494,7 @@ name = "ratatui" version = "0.29.0" source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cassowary", "compact_str", "crossterm", @@ -7289,16 +7558,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -7340,7 +7609,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7352,25 +7621,25 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata", - "regex-syntax 0.8.8", + "regex-syntax 0.8.9", ] [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.8", + "regex-syntax 0.8.9", ] [[package]] name = "regex-lite" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" @@ -7380,9 +7649,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" @@ -7407,6 +7676,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -7430,7 +7700,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] @@ -7499,7 +7769,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7547,7 +7817,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.114", + "syn 2.0.117", "walkdir", ] @@ -7613,7 +7883,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7622,14 +7892,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -7655,10 +7925,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework 3.7.0", ] [[package]] @@ -7695,7 +7965,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "clipboard-win", "fd-lock", @@ -7713,9 +7983,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "salsa20" @@ -7842,7 +8112,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7854,7 +8124,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -7929,7 +8199,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -7938,11 +8208,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7951,9 +8221,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -7982,10 +8252,11 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "sentry" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" +checksum = "d92d893ba7469d361a6958522fa440e4e2bc8bf4c5803cd1bf40b9af63f8f9a8" dependencies = [ + "cfg_aliases 0.2.1", "httpdate", "native-tls", "reqwest", @@ -8002,9 +8273,9 @@ dependencies = [ [[package]] name = "sentry-actix" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18bac0f6b8621fa0f85e298901e51161205788322e1a995e3764329020368058" +checksum = "56cb150fd6b55b3023714a3aaa1e3bdadfd44f164efc54fad69efc69aac36887" dependencies = [ "actix-http", "actix-web", @@ -8015,9 +8286,9 @@ dependencies = [ [[package]] name = "sentry-backtrace" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb1ef7534f583af20452b1b1bf610a60ed9c8dd2d8485e7bd064efc556a78fb" +checksum = "5f8784d0a27b5cd4b5f75769ffc84f0b7580e3c35e1af9cd83cb90b612d769cc" dependencies = [ "backtrace", "regex", @@ -8026,9 +8297,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd6be899d9938390b6d1ec71e2f53bd9e57b6a9d8b1d5b049e5c364e7da9078" +checksum = "0e5eb42f4cd4f9fdfec9e3b07b25a4c9769df83d218a7e846658984d5948ad3e" dependencies = [ "hostname", "libc", @@ -8040,9 +8311,9 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ab054c34b87f96c3e4701bea1888317cde30cc7e4a6136d2c48454ab96661c" +checksum = "b0b1e7ca40f965db239da279bf278d87b7407469b98835f27f0c8e59ed189b06" dependencies = [ "rand 0.9.2", "sentry-types", @@ -8053,9 +8324,9 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5637ec550dc6f8c49a711537950722d3fc4baa6fd433c371912104eaff31e2a5" +checksum = "002561e49ea3a9de316e2efadc40fae553921b8ff41448f02ea85fd135a778d6" dependencies = [ "findshlibs", "sentry-core", @@ -8063,9 +8334,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f02c7162f7b69b8de872b439d4696dc1d65f80b13ddd3c3831723def4756b63" +checksum = "8906f8be87aea5ac7ef937323fb655d66607427f61007b99b7cb3504dc5a156c" dependencies = [ "sentry-backtrace", "sentry-core", @@ -8073,11 +8344,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1dd47df349a80025819f3d25c3d2f751df705d49c65a4cdc0f130f700972a48" +checksum = "5b07eefe04486316c57aba08ab53dd44753c25102d1d3fe05775cc93a13262d9" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "sentry-backtrace", "sentry-core", "tracing-core", @@ -8086,9 +8357,9 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.46.1" +version = "0.46.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eecbd63e9d15a26a40675ed180d376fcb434635d2e33de1c24003f61e3e2230d" +checksum = "567711f01f86a842057e1fc17779eba33a336004227e1a1e7e6cc2599e22e259" dependencies = [ "debugid", "hex", @@ -8128,7 +8399,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8139,7 +8410,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8188,7 +8459,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8240,7 +8511,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8258,9 +8529,9 @@ dependencies = [ [[package]] name = "serial2" -version = "0.2.33" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cc76fa68e25e771492ca1e3c53d447ef0be3093e05cd3b47f4b712ba10c6f3c" +checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" dependencies = [ "cfg-if", "libc", @@ -8269,9 +8540,9 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" dependencies = [ "futures-executor", "futures-util", @@ -8284,13 +8555,13 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8549,7 +8820,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8572,7 +8843,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.114", + "syn 2.0.117", "tokio", "url", ] @@ -8585,7 +8856,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -8630,7 +8901,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -8757,7 +9028,7 @@ dependencies = [ "dupe", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8879,7 +9150,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8891,7 +9162,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8932,9 +9203,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -8958,7 +9229,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -8973,7 +9244,7 @@ dependencies = [ "once_cell", "onig", "plist", - "regex-syntax 0.8.8", + "regex-syntax 0.8.9", "serde", "serde_derive", "serde_json", @@ -8993,11 +9264,11 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -9020,14 +9291,14 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -9057,7 +9328,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -9085,7 +9356,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9096,7 +9367,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "test-case-core", ] @@ -9119,7 +9390,7 @@ checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9168,7 +9439,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9179,7 +9450,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9323,7 +9594,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9410,9 +9681,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", @@ -9446,9 +9717,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.24.0+spec-1.1.0" +version = "0.24.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" +checksum = "01f2eadbbc6b377a847be05f60791ef1058d9f696ecb51d2c07fe911d8569d8e" dependencies = [ "indexmap 2.13.0", "toml_datetime", @@ -9459,9 +9730,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -9474,9 +9745,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "base64 0.22.1", @@ -9502,9 +9773,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes", "prost", @@ -9536,7 +9807,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", @@ -9592,7 +9863,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9675,9 +9946,9 @@ dependencies = [ [[package]] name = "tracing-test" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" dependencies = [ "tracing-core", "tracing-subscriber", @@ -9686,12 +9957,12 @@ dependencies = [ [[package]] name = "tracing-test-macro" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -9702,7 +9973,7 @@ checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" dependencies = [ "cc", "regex", - "regex-syntax 0.8.8", + "regex-syntax 0.8.9", "serde_json", "streaming-iterator", "tree-sitter-language", @@ -9761,7 +10032,7 @@ checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "termcolor", ] @@ -9870,9 +10141,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -9960,9 +10231,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.1.4" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" dependencies = [ "base64 0.22.1", "der", @@ -10026,11 +10297,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.1", "js-sys", "serde_core", "sha1_smol", @@ -10119,6 +10390,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -10127,9 +10407,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" dependencies = [ "cfg-if", "once_cell", @@ -10140,9 +10420,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" dependencies = [ "cfg-if", "futures-util", @@ -10154,9 +10434,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10164,26 +10444,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -10197,6 +10499,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -10205,7 +10519,7 @@ checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "smallvec", "wayland-sys", ] @@ -10216,8 +10530,8 @@ version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags 2.10.0", - "rustix 1.1.3", + "bitflags 2.11.0", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -10228,7 +10542,7 @@ version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -10240,7 +10554,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -10269,9 +10583,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" dependencies = [ "js-sys", "wasm-bindgen", @@ -10289,9 +10603,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" dependencies = [ "core-foundation 0.10.1", "jni", @@ -10305,9 +10619,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -10318,14 +10632,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.5", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -10343,7 +10657,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix 1.1.3", + "rustix 1.1.4", "winsafe", ] @@ -10409,6 +10723,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -10440,6 +10764,16 @@ dependencies = [ "windows-core 0.62.2", ] +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -10485,7 +10819,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10496,7 +10830,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10507,7 +10841,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10518,7 +10852,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -10548,6 +10882,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -10968,6 +11311,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -10978,7 +11403,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.3", + "rustix 1.1.4", "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", @@ -11000,7 +11425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -11103,7 +11528,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -11154,7 +11579,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -11171,22 +11596,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.37" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.37" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11206,7 +11631,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -11227,7 +11652,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11261,7 +11686,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -11296,9 +11721,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zopfli" @@ -11392,7 +11817,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -11404,5 +11829,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e22dba12c2..1ba1cac37b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "cli", "config", "shell-command", + "shell-escalation", "skills", "core", "hooks", @@ -113,6 +114,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } +codex-shell-escalation = { path = "shell-escalation" } codex-skills = { path = "skills" } codex-state = { path = "state" } codex-stdio-to-uds = { path = "stdio-to-uds" } @@ -188,7 +190,7 @@ itertools = "0.14.0" keyring = { version = "3.6", default-features = false } landlock = "0.4.4" lazy_static = "1" -libc = "0.2.177" +libc = "0.2.182" log = "0.4" lru = "0.16.3" maplit = "1.0.2" @@ -204,7 +206,7 @@ opentelemetry-otlp = "0.31.0" opentelemetry-semantic-conventions = "0.31.0" opentelemetry_sdk = "0.31.0" os_info = "3.12.0" -owo-colors = "4.2.0" +owo-colors = "4.3.0" path-absolutize = "3.1.1" pathdiff = "0.2" portable-pty = "0.9.0" diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index b4c5d098e4..c56c3c8f98 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -23,10 +23,12 @@ struct AppServerArgs { } fn main() -> anyhow::Result<()> { - if codex_core::maybe_run_zsh_exec_wrapper_mode()? { - return Ok(()); - } arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + // Run wrapper mode only after arg0 dispatch so `codex-linux-sandbox` + // invocations don't get misclassified as zsh exec-wrapper calls. + if codex_core::maybe_run_zsh_exec_wrapper_mode()? { + return Ok(()); + } let args = AppServerArgs::parse(); let managed_config_path = managed_config_path_from_debug_env(); let loader_overrides = LoaderOverrides { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e80e1790f8..de4418f0e9 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -49,6 +49,7 @@ use codex_core::default_client::set_default_originator; use codex_feedback::CodexFeedback; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; +use futures::FutureExt; use tokio::sync::broadcast; use tokio::time::Duration; use tokio::time::timeout; @@ -386,8 +387,12 @@ impl MessageProcessor { .await; } other => { + // Box the delegated future so this wrapper's async state machine does not + // inline the full `CodexMessageProcessor::process_request` future, which + // can otherwise push worker-thread stack usage over the edge. self.codex_message_processor .process_request(connection_id, other) + .boxed() .await; } } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 94467b8719..e989807ea9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -2,18 +2,15 @@ // // Running these tests with the patched zsh fork: // -// The suite uses `CODEX_TEST_ZSH_PATH` when set. Example: -// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \ -// cargo test -p codex-app-server turn_start_zsh_fork -- --nocapture -// -// For a single test: -// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \ -// cargo test -p codex-app-server turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2 -- --nocapture +// The suite resolves the shared test-only zsh DotSlash file at +// `exec-server/tests/suite/zsh` via DotSlash on first use, so `dotslash` and +// network access are required the first time the artifact is fetched. use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -38,6 +35,7 @@ use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +55,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork test: no zsh executable found"); return Ok(()); }; @@ -82,7 +80,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { &zsh_path, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -167,7 +165,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork decline test: no zsh executable found"); return Ok(()); }; @@ -199,7 +197,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { &zsh_path, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -303,7 +301,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork cancel test: no zsh executable found"); return Ok(()); }; @@ -332,7 +330,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { &zsh_path, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -434,7 +432,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork subcommand decline test: no zsh executable found"); return Ok(()); }; @@ -446,6 +444,29 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() return Ok(()); } eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + let zsh_path_for_config = { + // App-server config accepts only a zsh path, not extra argv. Use a + // wrapper so this test can force `-df` and downgrade `-lc` to `-c` + // to avoid rc/login-shell startup noise. + let path = workspace.join("zsh-no-rc"); + std::fs::write( + &path, + format!( + r#"#!/bin/sh +if [ "$1" = "-lc" ]; then + shift + set -- -c "$@" +fi +exec "{}" -df "$@" +"#, + zsh_path.display() + ), + )?; + let mut permissions = std::fs::metadata(&path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions)?; + path + }; let tool_call_arguments = serde_json::to_string(&serde_json::json!({ "command": "/usr/bin/true && /usr/bin/true", @@ -461,7 +482,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() ), responses::ev_completed("resp-1"), ]); - let server = create_mock_responses_server_sequence(vec![response]).await; + let no_op_response = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_completed("resp-2"), + ]); + // Linux CI has occasionally issued a second `/responses` POST after the + // subcommand-decline flow. This test is about approval/decline behavior in + // the zsh fork, not exact model request count, so allow an extra request + // and return a harmless no-op response if it arrives. + let server = + create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; create_config_toml( &codex_home, &server.uri(), @@ -471,10 +501,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() (Feature::UnifiedExec, false), (Feature::ShellSnapshot, false), ]), - &zsh_path, + &zsh_path_for_config, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -500,8 +530,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() }], cwd: Some(workspace.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::OnRequest), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, + sandbox_policy: Some(if cfg!(target_os = "linux") { + // The zsh exec-bridge wrapper uses a Unix socket back to the parent + // process. Linux restricted sandbox seccomp denies connect(2), so use + // full access here; this test is validating zsh approval/decline + // behavior, not Linux sandboxing. + codex_app_server_protocol::SandboxPolicy::DangerFullAccess + } else { + codex_app_server_protocol::SandboxPolicy::ReadOnly { + access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, + } }), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), @@ -517,10 +555,13 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let TurnStartResponse { turn } = to_response::(turn_resp)?; let mut approval_ids = Vec::new(); - for decision in [ + let mut saw_parent_approval = false; + let target_decisions = [ CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::Cancel, - ] { + ]; + let mut target_decision_index = 0; + while target_decision_index < target_decisions.len() { let server_req = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_request_message(), @@ -531,13 +572,40 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() panic!("expected CommandExecutionRequestApproval request"); }; assert_eq!(params.item_id, "call-zsh-fork-subcommand-decline"); - approval_ids.push( - params - .approval_id - .clone() - .expect("approval_id must be present for zsh subcommand approvals"), - ); assert_eq!(params.thread_id, thread.id); + let is_target_subcommand = params.command.as_deref() == Some("/usr/bin/true"); + if is_target_subcommand { + approval_ids.push( + params + .approval_id + .clone() + .expect("approval_id must be present for zsh subcommand approvals"), + ); + } + let decision = if is_target_subcommand { + let decision = target_decisions[target_decision_index].clone(); + target_decision_index += 1; + decision + } else { + let command = params + .command + .as_deref() + .expect("approval command should be present"); + assert!( + !saw_parent_approval, + "unexpected extra non-target approval: {command}" + ); + assert!( + command.contains("zsh-no-rc"), + "expected parent zsh wrapper approval, got: {command}" + ); + assert!( + command.contains("/usr/bin/true && /usr/bin/true"), + "expected tool command in parent approval, got: {command}" + ); + saw_parent_approval = true; + CommandExecutionApprovalDecision::Accept + }; mcp.send_response( request_id, serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?, @@ -545,6 +613,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() .await?; } + assert_eq!(approval_ids.len(), 2); + assert_ne!(approval_ids[0], approval_ids[1]); let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { loop { let completed_notif = mcp @@ -563,32 +633,61 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() } } }) - .await??; + .await; - let ThreadItem::CommandExecution { - id, - status, - aggregated_output, - .. - } = parent_completed_command_execution - else { - unreachable!("loop ensures we break on parent command execution item"); - }; - assert_eq!(id, "call-zsh-fork-subcommand-decline"); - assert_eq!(status, CommandExecutionStatus::Declined); - assert!( - aggregated_output.is_none() - || aggregated_output == Some("exec command rejected by user".to_string()) - ); - assert_eq!(approval_ids.len(), 2); - assert_ne!(approval_ids[0], approval_ids[1]); + match parent_completed_command_execution { + Ok(Ok(parent_completed_command_execution)) => { + let ThreadItem::CommandExecution { + id, + status, + aggregated_output, + .. + } = parent_completed_command_execution + else { + unreachable!("loop ensures we break on parent command execution item"); + }; + assert_eq!(id, "call-zsh-fork-subcommand-decline"); + assert_eq!(status, CommandExecutionStatus::Declined); + assert!( + aggregated_output.is_none() + || aggregated_output == Some("exec command rejected by user".to_string()) + ); - mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) - .await?; + mcp.interrupt_turn_and_wait_for_aborted( + thread.id.clone(), + turn.id.clone(), + DEFAULT_READ_TIMEOUT, + ) + .await?; + } + Ok(Err(error)) => return Err(error), + Err(_) => { + // Some zsh builds abort the turn immediately after the rejected + // subcommand without emitting a parent `item/completed`. + let completed_notif = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Interrupted); + } + } Ok(()) } +async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result { + let zdotdir = zdotdir.to_string_lossy().into_owned(); + McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await +} + fn create_config_toml( codex_home: &Path, server_uri: &str, @@ -640,36 +739,24 @@ stream_max_retries = 0 ) } -fn find_test_zsh_path() -> Option { - if let Some(path) = std::env::var_os("CODEX_TEST_ZSH_PATH") { - let path = std::path::PathBuf::from(path); - if path.is_file() { - return Some(path); - } - panic!( - "CODEX_TEST_ZSH_PATH is set but is not a file: {}", - path.display() +fn find_test_zsh_path() -> Result> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let dotslash_zsh = repo_root.join("codex-rs/exec-server/tests/suite/zsh"); + if !dotslash_zsh.is_file() { + eprintln!( + "skipping zsh fork test: shared zsh DotSlash file not found at {}", + dotslash_zsh.display() ); + return Ok(None); } - - for candidate in ["/bin/zsh", "/usr/bin/zsh"] { - let path = Path::new(candidate); - if path.is_file() { - return Some(path.to_path_buf()); + match core_test_support::fetch_dotslash_file(&dotslash_zsh, None) { + Ok(path) => return Ok(Some(path)), + Err(error) => { + eprintln!("failed to fetch vendored zsh via dotslash: {error:#}"); } } - let shell = std::env::var_os("SHELL")?; - let shell_path = std::path::PathBuf::from(shell); - if shell_path - .file_name() - .is_some_and(|file_name| file_name == "zsh") - && shell_path.is_file() - { - return Some(shell_path); - } - - None + Ok(None) } fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e5478f1b90..483516fe6e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -543,10 +543,12 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str { } fn main() -> anyhow::Result<()> { - if codex_core::maybe_run_zsh_exec_wrapper_mode()? { - return Ok(()); - } arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + // Run wrapper mode only after arg0 dispatch so `codex-linux-sandbox` + // invocations don't get misclassified as zsh exec-wrapper calls. + if codex_core::maybe_run_zsh_exec_wrapper_mode()? { + return Ok(()); + } cli_main(codex_linux_sandbox_exe).await?; Ok(()) }) @@ -1110,6 +1112,34 @@ mod tests { assert_eq!(args.prompt.as_deref(), Some("2+2")); } + #[test] + fn exec_resume_accepts_output_last_message_flag_after_subcommand() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "exec", + "resume", + "session-123", + "-o", + "/tmp/resume-output.md", + "re-review", + ]) + .expect("parse should succeed"); + + let Some(Subcommand::Exec(exec)) = cli.subcommand else { + panic!("expected exec subcommand"); + }; + let Some(codex_exec::Command::Resume(args)) = exec.command else { + panic!("expected exec resume"); + }; + + assert_eq!( + exec.last_message_file, + Some(std::path::PathBuf::from("/tmp/resume-output.md")) + ); + assert_eq!(args.session_id.as_deref(), Some("session-123")); + assert_eq!(args.prompt.as_deref(), Some("re-review")); + } + fn app_server_from_args(args: &[&str]) -> AppServerCommand { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else { diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 8fa07e0a49..6b2ed72f8a 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -49,10 +49,12 @@ async fn features_enable_under_development_feature_prints_warning() -> Result<() let codex_home = TempDir::new()?; let mut cmd = codex_command(codex_home.path())?; - cmd.args(["features", "enable", "sqlite"]) + cmd.args(["features", "enable", "runtime_metrics"]) .assert() .success() - .stderr(contains("Under-development features enabled: sqlite.")); + .stderr(contains( + "Under-development features enabled: runtime_metrics.", + )); Ok(()) } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6a0f0a1745..6c29fa55c6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -349,6 +349,9 @@ "js_repl_tools_only": { "type": "boolean" }, + "memories": { + "type": "boolean" + }, "memory_tool": { "type": "boolean" }, @@ -412,6 +415,9 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, + "voice_transcription": { + "type": "boolean" + }, "web_search": { "type": "boolean" }, @@ -1627,6 +1633,9 @@ "js_repl_tools_only": { "type": "boolean" }, + "memories": { + "type": "boolean" + }, "memory_tool": { "type": "boolean" }, @@ -1690,6 +1699,9 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, + "voice_transcription": { + "type": "boolean" + }, "web_search": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/builtins/monitor.toml b/codex-rs/core/src/agent/builtins/awaiter.toml similarity index 53% rename from codex-rs/core/src/agent/builtins/monitor.toml rename to codex-rs/core/src/agent/builtins/awaiter.toml index 5e48a4936c..a34583c0c9 100644 --- a/codex-rs/core/src/agent/builtins/monitor.toml +++ b/codex-rs/core/src/agent/builtins/awaiter.toml @@ -1,36 +1,35 @@ background_terminal_max_timeout = 3600000 model_reasoning_effort = "low" -developer_instructions="""You are a waiting agent. -You're name is Superman. -Your role is to monitor the execution of a specific command or task and report its status only when it is finished. +developer_instructions="""You are an awaiter. +Your role is to await the completion of a specific command or task and report its status only when it is finished. Behavior rules: 1. When given a command or task identifier, you must: - - Execute or monitor it using the appropriate tool - - Continue waiting until the task reaches a terminal state. + - Execute or await it using the appropriate tool + - Continue awaiting until the task reaches a terminal state. 2. You must NOT: - Modify the task. - Interpret or optimize the task. - Perform unrelated actions. - - Stop waiting unless explicitly instructed. + - Stop awaiting unless explicitly instructed. -3. Waiting behavior: +3. Awaiting behavior: - If the task is still running, continue polling using tool calls. - Use repeated tool calls if necessary. - Do not hallucinate completion. - - Use long timeouts when waiting for something. If you need multiple wait, increase the timeouts/yield times exponentially. + - Use long timeouts when awaiting for something. If you need multiple awaits, increase the timeouts/yield times exponentially. 4. If asked for status: - Return the current known status. - - Immediately resume waiting afterward. + - Immediately resume awaiting afterward. 5. Termination: - - Only exit waiting when: + - Only exit awaiting when: - The task completes successfully, OR - The task fails, OR - You receive an explicit stop instruction. You must behave deterministically and conservatively. -""" \ No newline at end of file +""" diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 223373c6df..422e10b651 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -118,6 +118,9 @@ impl Guards { } else { active_agents.used_agent_nicknames.clear(); active_agents.nickname_reset_count += 1; + if let Some(metrics) = codex_otel::metrics::global() { + let _ = metrics.counter("codex.multi_agent.nickname_pool_reset", 1, &[]); + } names.choose(&mut rand::rng())?.to_string() } }; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index f4bdb9d7af..8cc18c1ad1 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -13,7 +13,7 @@ use std::path::Path; use std::sync::LazyLock; use toml::Value as TomlValue; -const DEFAULT_ROLE_NAME: &str = "default"; +pub const DEFAULT_ROLE_NAME: &str = "default"; const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; /// Applies a role config layer to a mutable config and preserves unspecified keys. @@ -187,16 +187,16 @@ Rules: } ), ( - "monitor".to_string(), + "awaiter".to_string(), AgentRoleConfig { - description: Some(r#"Use a `monitor` agent EVERY TIME you must run a command that might take some time. + description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that might take some time. This includes, but not only: * testing * monitoring of a long running process * explicit ask to wait for something -When YOU wait for the `monitor` agent to be done, use the largest possible timeout."#.to_string()), - config_file: Some("monitor.toml".to_string().parse().unwrap_or_default()), +When YOU wait for the `awaiter` agent to be done, use the largest possible timeout."#.to_string()), + config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()), } ) ]) @@ -207,10 +207,10 @@ When YOU wait for the `monitor` agent to be done, use the largest possible timeo /// Resolves a built-in role `config_file` path to embedded content. pub(super) fn config_file_contents(path: &Path) -> Option<&'static str> { const EXPLORER: &str = include_str!("builtins/explorer.toml"); - const MONITOR: &str = include_str!("builtins/monitor.toml"); + const AWAITER: &str = include_str!("builtins/awaiter.toml"); match path.to_str()? { "explorer.toml" => Some(EXPLORER), - "monitor.toml" => Some(MONITOR), + "awaiter.toml" => Some(AWAITER), _ => None, } } diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4cefe4b216..151917f50d 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -134,9 +134,12 @@ pub enum Feature { /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, /// Enable collaboration modes (Plan, Default). + /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. CollaborationModes, /// Enable personality selection in the TUI. Personality, + /// Enable voice transcription in the TUI composer. + VoiceTranscription, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, /// Use the Responses API WebSocket transport for OpenAI by default. @@ -494,12 +497,12 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Sqlite, key: "sqlite", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::MemoryTool, - key: "memory_tool", + key: "memories", stage: Stage::UnderDevelopment, default_enabled: false, }, @@ -617,7 +620,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::CollaborationModes, key: "collaboration_modes", - stage: Stage::Stable, + stage: Stage::Removed, default_enabled: true, }, FeatureSpec { @@ -626,6 +629,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::VoiceTranscription, + key: "voice_transcription", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::PreventIdleSleep, key: "prevent_idle_sleep", @@ -728,10 +737,9 @@ mod tests { fn default_enabled_features_are_stable() { for spec in FEATURES { if spec.default_enabled { - assert_eq!( - spec.stage, - Stage::Stable, - "feature `{}` is enabled by default but is not stable ({:?})", + assert!( + matches!(spec.stage, Stage::Stable | Stage::Removed), + "feature `{}` is enabled by default but is not stable/removed ({:?})", spec.key, spec.stage ); diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 41eab93bd1..45b3dfd5dc 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -37,6 +37,10 @@ const ALIASES: &[Alias] = &[ legacy_key: "collab", feature: Feature::Collab, }, + Alias { + legacy_key: "memory_tool", + feature: Feature::MemoryTool, + }, ]; pub(crate) fn legacy_feature_keys() -> impl Iterator { diff --git a/codex-rs/core/src/memories/README.md b/codex-rs/core/src/memories/README.md new file mode 100644 index 0000000000..c19a0c6806 --- /dev/null +++ b/codex-rs/core/src/memories/README.md @@ -0,0 +1,92 @@ +# Memories Pipeline (Core) + +This module runs a startup memory pipeline for eligible sessions. + +## When it runs + +The pipeline is triggered when a root session starts, and only if: + +- the session is not ephemeral +- the memory feature is enabled +- the session is not a sub-agent session +- the state DB is available + +It runs asynchronously in the background and executes two phases in order: Phase 1, then Phase 2. + +## Phase 1: Rollout Extraction (per-thread) + +Phase 1 finds recent eligible rollouts and extracts a structured memory from each one. + +Eligible rollouts are selected from the state DB using startup claim rules. In practice this means +the pipeline only considers rollouts that are: + +- from allowed interactive session sources +- within the configured age window +- idle long enough (to avoid summarizing still-active/fresh rollouts) +- not already owned by another in-flight phase-1 worker +- within startup scan/claim limits (bounded work per startup) + +What it does: + +- claims a bounded set of rollout jobs from the state DB (startup claim) +- filters rollout content down to memory-relevant response items +- sends each rollout to a model (in parallel, with a concurrency cap) +- expects structured output containing: + - a detailed `raw_memory` + - a compact `rollout_summary` + - an optional `rollout_slug` +- redacts secrets from the generated memory fields +- stores successful outputs back into the state DB as stage-1 outputs + +Concurrency / coordination: + +- Phase 1 runs multiple extraction jobs in parallel (with a fixed concurrency cap) so startup memory generation can process several rollouts at once. +- Each job is leased/claimed in the state DB before processing, which prevents duplicate work across concurrent workers/startups. +- Failed jobs are marked with retry backoff, so they are retried later instead of hot-looping. + +Job outcomes: + +- `succeeded` (memory produced) +- `succeeded_no_output` (valid run but nothing useful generated) +- `failed` (with retry backoff/lease handling in DB) + +Phase 1 is the stage that turns individual rollouts into DB-backed memory records. + +## Phase 2: Global Consolidation + +Phase 2 consolidates the latest stage-1 outputs into the filesystem memory artifacts and then runs a dedicated consolidation agent. + +What it does: + +- claims a single global phase-2 job (so only one consolidation runs at a time) +- loads a bounded set of the most recent stage-1 outputs from the state DB (the per-rollout memories produced by Phase 1, used as the consolidation input set) +- computes a completion watermark from the claimed watermark + newest input timestamps +- syncs local memory artifacts under the memories root: + - `raw_memories.md` (merged raw memories, latest first) + - `rollout_summaries/` (one summary file per retained rollout) +- prunes stale rollout summaries that are no longer retained +- if there are no inputs, marks the job successful and exits + +If there is input, it then: + +- spawns an internal consolidation sub-agent +- runs it with no approvals, no network, and local write access only +- disables collab for that agent (to prevent recursive delegation) +- watches the agent status and heartbeats the global job lease while it runs +- marks the phase-2 job success/failure in the state DB when the agent finishes + +Watermark behavior: + +- The global phase-2 job claim includes an input watermark representing the latest input timestamp known when the job was claimed. +- Phase 2 recomputes a `new_watermark` using the max of: + - the claimed watermark + - the newest `source_updated_at` timestamp in the stage-1 inputs it actually loaded +- On success, Phase 2 stores that completion watermark in the DB. +- This lets later phase-2 runs know whether new stage-1 data arrived since the last successful consolidation (dirty vs not dirty), while also avoiding moving the watermark backwards. + +In practice, this phase is responsible for refreshing the on-disk memory workspace and producing/updating the higher-level consolidated memory outputs. + +## Why it is split into two phases + +- Phase 1 scales across many rollouts and produces normalized per-rollout memory records. +- Phase 2 serializes global consolidation so the shared memory artifacts are updated safely and consistently. diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 7187214fba..47a4354e2d 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -217,7 +217,8 @@ pub(crate) async fn handle_start( _ => None, }; if let Some(text) = maybe_routed_text { - sess_clone.route_realtime_text_input(text).await; + let sess_for_routed_text = Arc::clone(&sess_clone); + sess_for_routed_text.route_realtime_text_input(text).await; } sess_clone .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( @@ -252,16 +253,25 @@ pub(crate) async fn handle_audio( } fn realtime_text_from_conversation_item(item: &Value) -> Option { - if item.get("type").and_then(Value::as_str) != Some("message") { - return None; + match item.get("type").and_then(Value::as_str) { + Some("message") => { + if item.get("role").and_then(Value::as_str) != Some("assistant") { + return None; + } + let content = item.get("content")?.as_array()?; + let text = content + .iter() + .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|entry| entry.get("text").and_then(Value::as_str)) + .collect::(); + if text.is_empty() { None } else { Some(text) } + } + Some("spawn_transcript") => item + .get("delta_user_transcript") + .and_then(Value::as_str) + .and_then(|text| (!text.is_empty()).then(|| text.to_string())), + Some(_) | None => None, } - let content = item.get("content")?.as_array()?; - let text = content - .iter() - .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|entry| entry.get("text").and_then(Value::as_str)) - .collect::(); - if text.is_empty() { None } else { Some(text) } } pub(crate) async fn handle_text( @@ -301,7 +311,6 @@ fn spawn_realtime_input_task( tokio::spawn(async move { loop { tokio::select! { - biased; text = text_rx.recv() => { match text { Ok(text) => { @@ -388,7 +397,7 @@ mod tests { use serde_json::json; #[test] - fn extracts_text_from_message_items_ignoring_role() { + fn extracts_text_from_assistant_message_items_only() { let assistant = json!({ "type": "message", "role": "assistant", @@ -404,16 +413,14 @@ mod tests { "role": "user", "content": [{"type": "text", "text": "world"}], }); - assert_eq!( - realtime_text_from_conversation_item(&user), - Some("world".to_string()) - ); + assert_eq!(realtime_text_from_conversation_item(&user), None); } #[test] fn extracts_and_concatenates_text_entries_only() { let item = json!({ "type": "message", + "role": "assistant", "content": [ {"type": "text", "text": "a"}, {"type": "ignored", "text": "x"}, @@ -436,8 +443,31 @@ mod tests { let no_text = json!({ "type": "message", + "role": "assistant", "content": [{"type": "other", "value": 1}], }); assert_eq!(realtime_text_from_conversation_item(&no_text), None); + + let empty_spawn_transcript = json!({ + "type": "spawn_transcript", + "delta_user_transcript": "", + }); + assert_eq!( + realtime_text_from_conversation_item(&empty_spawn_transcript), + None + ); + } + + #[test] + fn extracts_text_from_spawn_transcript_items() { + let item = json!({ + "type": "spawn_transcript", + "delta_user_transcript": "delegate from transcript", + "backend_prompt_messages": [{"role": "user", "content": "delegate from transcript"}], + }); + assert_eq!( + realtime_text_from_conversation_item(&item), + Some("delegate from transcript".to_string()) + ); } } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 3ef79f7aa0..ae93327b81 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -31,6 +31,7 @@ use async_trait::async_trait; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchFileChange; use codex_utils_absolute_path::AbsolutePathBuf; +use std::sync::Arc; pub struct ApplyPatchHandler; @@ -139,8 +140,8 @@ impl ToolHandler for ApplyPatchHandler { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ApplyPatchRuntime::new(); let tool_ctx = ToolCtx { - session: session.as_ref(), - turn: turn.as_ref(), + session: session.clone(), + turn: turn.clone(), call_id: call_id.clone(), tool_name: tool_name.to_string(), }; @@ -149,7 +150,7 @@ impl ToolHandler for ApplyPatchHandler { &mut runtime, &req, &tool_ctx, - &turn, + turn.as_ref(), turn.approval_policy.value(), ) .await @@ -193,8 +194,8 @@ pub(crate) async fn intercept_apply_patch( command: &[String], cwd: &Path, timeout_ms: Option, - session: &Session, - turn: &TurnContext, + session: Arc, + turn: Arc, tracker: Option<&SharedTurnDiffTracker>, call_id: &str, tool_name: &str, @@ -203,11 +204,13 @@ pub(crate) async fn intercept_apply_patch( codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => { session .record_model_warning( - format!("apply_patch was requested via {tool_name}. Use the apply_patch tool instead of exec_command."), - turn, + format!( + "apply_patch was requested via {tool_name}. Use the apply_patch tool instead of exec_command." + ), + turn.as_ref(), ) .await; - match apply_patch::apply_patch(turn, changes).await { + match apply_patch::apply_patch(turn.as_ref(), changes).await { InternalApplyPatchInvocation::Output(item) => { let content = item?; Ok(Some(ToolOutput::Function { @@ -219,8 +222,12 @@ pub(crate) async fn intercept_apply_patch( let changes = convert_apply_patch_to_protocol(&apply.action); let approval_keys = file_paths_for_action(&apply.action); let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); - let event_ctx = - ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied()); + let event_ctx = ToolEventCtx::new( + session.as_ref(), + turn.as_ref(), + call_id, + tracker.as_ref().copied(), + ); emitter.begin(event_ctx).await; let req = ApplyPatchRequest { @@ -235,8 +242,8 @@ pub(crate) async fn intercept_apply_patch( let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ApplyPatchRuntime::new(); let tool_ctx = ToolCtx { - session, - turn, + session: session.clone(), + turn: turn.clone(), call_id: call_id.to_string(), tool_name: tool_name.to_string(), }; @@ -245,13 +252,17 @@ pub(crate) async fn intercept_apply_patch( &mut runtime, &req, &tool_ctx, - turn, + turn.as_ref(), turn.approval_policy.value(), ) .await .map(|result| result.output); - let event_ctx = - ToolEventCtx::new(session, turn, call_id, tracker.as_ref().copied()); + let event_ctx = ToolEventCtx::new( + session.as_ref(), + turn.as_ref(), + call_id, + tracker.as_ref().copied(), + ); let content = emitter.finish(event_ctx, out).await?; Ok(Some(ToolOutput::Function { body: FunctionCallOutputBody::Text(content), diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index ae72c8b1ef..20be209fbe 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -93,6 +93,7 @@ impl ToolHandler for MultiAgentHandler { mod spawn { use super::*; + use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; use crate::agent::exceeds_thread_spawn_depth_limit; @@ -109,6 +110,7 @@ mod spawn { #[derive(Debug, Serialize)] struct SpawnAgentResult { agent_id: String, + nickname: Option, } pub async fn handle( @@ -183,6 +185,7 @@ mod spawn { .unwrap_or((None, None)), None => (None, None), }; + let nickname = new_agent_nickname.clone(); session .send_event( &turn, @@ -199,9 +202,13 @@ mod spawn { ) .await; let new_thread_id = result?; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.otel_manager + .counter("codex.multi_agent.spawn", 1, &[("role", role_tag)]); let content = serde_json::to_string(&SpawnAgentResult { agent_id: new_thread_id.to_string(), + nickname, }) .map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize spawn_agent result: {err}")) @@ -406,6 +413,8 @@ mod resume_agent { if let Some(err) = error { return Err(err); } + turn.otel_manager + .counter("codex.multi_agent.resume", 1, &[]); let content = serde_json::to_string(&ResumeAgentResult { status }).map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize resume_agent result: {err}")) @@ -1085,6 +1094,7 @@ mod tests { #[derive(Debug, Deserialize)] struct SpawnAgentResult { agent_id: String, + nickname: Option, } let (mut session, mut turn) = make_session_and_context().await; @@ -1121,6 +1131,12 @@ mod tests { let result: SpawnAgentResult = serde_json::from_str(&content).expect("spawn_agent result should be json"); let agent_id = agent_id(&result.agent_id).expect("agent_id should be valid"); + assert!( + result + .nickname + .as_deref() + .is_some_and(|nickname| !nickname.is_empty()) + ); let snapshot = manager .get_thread(agent_id) .await @@ -1184,6 +1200,7 @@ mod tests { #[derive(Debug, Deserialize)] struct SpawnAgentResult { agent_id: String, + nickname: Option, } let (mut session, mut turn) = make_session_and_context().await; @@ -1221,6 +1238,12 @@ mod tests { let result: SpawnAgentResult = serde_json::from_str(&content).expect("spawn_agent result should be json"); assert!(!result.agent_id.is_empty()); + assert!( + result + .nickname + .as_deref() + .is_some_and(|nickname| !nickname.is_empty()) + ); assert_eq!(success, Some(true)); } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 4cdbf454c4..46a90681d2 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -296,8 +296,8 @@ impl ShellHandler { &exec_params.command, &exec_params.cwd, exec_params.expiration.timeout_ms(), - session.as_ref(), - turn.as_ref(), + session.clone(), + turn.clone(), Some(&tracker), &call_id, tool_name.as_str(), @@ -343,8 +343,8 @@ impl ShellHandler { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ShellRuntime::new(); let tool_ctx = ToolCtx { - session: session.as_ref(), - turn: turn.as_ref(), + session: session.clone(), + turn: turn.clone(), call_id: call_id.clone(), tool_name, }; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c7d57731e3..76dbade19e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -172,8 +172,8 @@ impl ToolHandler for UnifiedExecHandler { &command, &cwd, Some(yield_time_ms), - context.session.as_ref(), - context.turn.as_ref(), + context.session.clone(), + context.turn.clone(), Some(&tracker), &context.call_id, tool_name.as_str(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index cfcd5ec2ab..6337cef3e0 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::openai_models::InputModality; use serde::Deserialize; use tokio::fs; @@ -14,7 +15,6 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::local_image_content_items_with_label_number; pub struct ViewImageHandler; @@ -81,21 +81,20 @@ impl ToolHandler for ViewImageHandler { } let event_path = abs_path.clone(); - let content: Vec = - local_image_content_items_with_label_number(&abs_path, None); - let input = ResponseInputItem::Message { - role: "user".to_string(), - content, - }; - - session - .inject_response_items(vec![input]) - .await - .map_err(|_| { - FunctionCallError::RespondToModel( - "unable to attach image (no active task)".to_string(), - ) - })?; + let content = local_image_content_items_with_label_number(&abs_path, None) + .into_iter() + .map(|item| match item { + ContentItem::InputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + ContentItem::InputImage { image_url } => { + FunctionCallOutputContentItem::InputImage { image_url } + } + ContentItem::OutputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + }) + .collect(); session .send_event( @@ -108,7 +107,7 @@ impl ToolHandler for ViewImageHandler { .await; Ok(ToolOutput::Function { - body: FunctionCallOutputBody::Text("attached local image path".to_string()), + body: FunctionCallOutputBody::ContentItems(content), success: Some(true), }) } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index afb24d4b3c..db28c93131 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -48,7 +48,7 @@ impl ToolOrchestrator { async fn run_attempt( tool: &mut T, req: &Rq, - tool_ctx: &ToolCtx<'_>, + tool_ctx: &ToolCtx, attempt: &SandboxAttempt<'_>, has_managed_network_requirements: bool, ) -> (Result, Option) @@ -56,7 +56,7 @@ impl ToolOrchestrator { T: ToolRuntime, { let network_approval = begin_network_approval( - tool_ctx.session, + &tool_ctx.session, &tool_ctx.turn.sub_id, &tool_ctx.call_id, has_managed_network_requirements, @@ -65,8 +65,8 @@ impl ToolOrchestrator { .await; let attempt_tool_ctx = ToolCtx { - session: tool_ctx.session, - turn: tool_ctx.turn, + session: tool_ctx.session.clone(), + turn: tool_ctx.turn.clone(), call_id: tool_ctx.call_id.clone(), tool_name: tool_ctx.tool_name.clone(), }; @@ -79,7 +79,7 @@ impl ToolOrchestrator { match network_approval.mode() { NetworkApprovalMode::Immediate => { let finalize_result = - finish_immediate_network_approval(tool_ctx.session, network_approval).await; + finish_immediate_network_approval(&tool_ctx.session, network_approval).await; if let Err(err) = finalize_result { return (Err(err), None); } @@ -88,7 +88,7 @@ impl ToolOrchestrator { NetworkApprovalMode::Deferred => { let deferred = network_approval.into_deferred(); if run_result.is_err() { - finish_deferred_network_approval(tool_ctx.session, deferred).await; + finish_deferred_network_approval(&tool_ctx.session, deferred).await; return (run_result, None); } (run_result, deferred) @@ -100,7 +100,7 @@ impl ToolOrchestrator { &mut self, tool: &mut T, req: &Rq, - tool_ctx: &ToolCtx<'_>, + tool_ctx: &ToolCtx, turn_ctx: &crate::codex::TurnContext, approval_policy: AskForApproval, ) -> Result, ToolError> @@ -128,7 +128,7 @@ impl ToolOrchestrator { } ExecApprovalRequirement::NeedsApproval { reason, .. } => { let approval_ctx = ApprovalCtx { - session: tool_ctx.session, + session: &tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, @@ -256,7 +256,7 @@ impl ToolOrchestrator { && network_approval_context.is_none(); if !bypass_retry_approval { let approval_ctx = ApprovalCtx { - session: tool_ctx.session, + session: &tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: Some(retry_reason), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index d0ae2f61d7..749e1abd2d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -70,7 +70,7 @@ impl ApplyPatchRuntime { }) } - fn stdout_stream(ctx: &ToolCtx<'_>) -> Option { + fn stdout_stream(ctx: &ToolCtx) -> Option { Some(crate::exec::StdoutStream { sub_id: ctx.turn.sub_id.clone(), call_id: ctx.call_id.clone(), @@ -156,7 +156,7 @@ impl ToolRuntime for ApplyPatchRuntime { &mut self, req: &ApplyPatchRequest, attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx<'_>, + ctx: &ToolCtx, ) -> Result { let spec = Self::build_command_spec(req)?; let env = attempt diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index bcbeafadfe..11ad669169 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -60,7 +60,7 @@ impl ShellRuntime { Self } - fn stdout_stream(ctx: &ToolCtx<'_>) -> Option { + fn stdout_stream(ctx: &ToolCtx) -> Option { Some(crate::exec::StdoutStream { sub_id: ctx.turn.sub_id.clone(), call_id: ctx.call_id.clone(), @@ -150,7 +150,7 @@ impl ToolRuntime for ShellRuntime { fn network_approval_spec( &self, req: &ShellRequest, - _ctx: &ToolCtx<'_>, + _ctx: &ToolCtx, ) -> Option { req.network.as_ref()?; Some(NetworkApprovalSpec { @@ -163,7 +163,7 @@ impl ToolRuntime for ShellRuntime { &mut self, req: &ShellRequest, attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx<'_>, + ctx: &ToolCtx, ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); @@ -207,7 +207,7 @@ impl ToolRuntime for ShellRuntime { .session .services .zsh_exec_bridge - .execute_shell_request(&env, ctx.session, ctx.turn, &ctx.call_id) + .execute_shell_request(&env, &ctx.session, &ctx.turn, &ctx.call_id) .await; } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 201de7ad84..5e06ca3975 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -153,7 +153,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt fn network_approval_spec( &self, req: &UnifiedExecRequest, - _ctx: &ToolCtx<'_>, + _ctx: &ToolCtx, ) -> Option { req.network.as_ref()?; Some(NetworkApprovalSpec { @@ -166,7 +166,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &mut self, req: &UnifiedExecRequest, attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx<'_>, + ctx: &ToolCtx, ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 9721fed535..540eb16434 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -18,14 +18,14 @@ use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; +use futures::Future; +use futures::future::BoxFuture; +use serde::Serialize; use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::path::Path; - -use futures::Future; -use futures::future::BoxFuture; -use serde::Serialize; +use std::sync::Arc; #[derive(Clone, Default, Debug)] pub(crate) struct ApprovalStore { @@ -267,9 +267,9 @@ pub(crate) trait Sandboxable { } } -pub(crate) struct ToolCtx<'a> { - pub session: &'a Session, - pub turn: &'a TurnContext, +pub(crate) struct ToolCtx { + pub session: Arc, + pub turn: Arc, pub call_id: String, pub tool_name: String, } @@ -281,7 +281,7 @@ pub(crate) enum ToolError { } pub(crate) trait ToolRuntime: Approvable + Sandboxable { - fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx<'_>) -> Option { + fn network_approval_spec(&self, _req: &Req, _ctx: &ToolCtx) -> Option { None } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 7bd1fc9788..60bd8bb03e 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -65,7 +65,7 @@ impl ToolsConfig { let include_js_repl_tools_only = include_js_repl && features.enabled(Feature::JsReplToolsOnly); let include_collab_tools = features.enabled(Feature::Collab); - let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes); + let include_collaboration_modes_tools = true; let include_search_tool = features.enabled(Feature::Apps); let shell_type = if !features.enabled(Feature::ShellTool) { @@ -557,7 +557,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "spawn_agent".to_string(), description: - "Spawn a sub-agent for a well-scoped task. Returns the agent id to use to communicate with this agent." + "Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent." .to_string(), strict: false, parameters: JsonSchema::Object { @@ -1887,34 +1887,6 @@ mod tests { ); } - #[test] - fn request_user_input_requires_collaboration_modes_feature() { - let config = test_config(); - let model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - let mut features = Features::with_defaults(); - features.disable(Feature::CollaborationModes); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert!( - !tools.iter().any(|t| t.spec.name() == "request_user_input"), - "request_user_input should be disabled when collaboration_modes feature is off" - ); - - features.enable(Feature::CollaborationModes); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - features: &features, - web_search_mode: Some(WebSearchMode::Cached), - }); - let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert_contains_tool_names(&tools, &["request_user_input"]); - } - #[test] fn js_repl_requires_feature_flag() { let config = test_config(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 68c4f95ceb..a3da4ba6ba 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -594,8 +594,8 @@ impl UnifiedExecProcessManager { exec_approval_requirement, }; let tool_ctx = ToolCtx { - session: context.session.as_ref(), - turn: context.turn.as_ref(), + session: context.session.clone(), + turn: context.turn.clone(), call_id: context.call_id.clone(), tool_name: "exec_command".to_string(), }; @@ -604,7 +604,7 @@ impl UnifiedExecProcessManager { &mut runtime, &req, &tool_ctx, - context.turn.as_ref(), + &context.turn, context.turn.approval_policy.value(), ) .await diff --git a/codex-rs/core/src/zsh_exec_bridge/mod.rs b/codex-rs/core/src/zsh_exec_bridge/mod.rs index 2e3821acfe..8094a4b786 100644 --- a/codex-rs/core/src/zsh_exec_bridge/mod.rs +++ b/codex-rs/core/src/zsh_exec_bridge/mod.rs @@ -166,6 +166,10 @@ impl ZshExecBridge { })?; let mut cmd = tokio::process::Command::new(&command[0]); + #[cfg(unix)] + if let Some(arg0) = &req.arg0 { + cmd.arg0(arg0); + } if command.len() > 1 { cmd.args(&command[1..]); } @@ -459,7 +463,6 @@ fn run_exec_wrapper_mode() -> anyhow::Result<()> { argv: argv.clone(), cwd, }; - let mut stream = StdUnixStream::connect(&socket_path) .with_context(|| format!("connect to wrapper socket at {socket_path}"))?; let encoded = serde_json::to_string(&request).context("serialize wrapper request")?; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index a4422e4580..a26cd5ae77 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -1,5 +1,7 @@ #![expect(clippy::expect_used)] +use anyhow::Context as _; +use anyhow::ensure; use codex_utils_cargo_bin::CargoBinError; use ctor::ctor; use tempfile::TempDir; @@ -99,6 +101,42 @@ pub fn test_tmp_path_buf() -> PathBuf { test_tmp_path().into_path_buf() } +/// Fetch a DotSlash resource and return the resolved executable/file path. +pub fn fetch_dotslash_file( + dotslash_file: &std::path::Path, + dotslash_cache: Option<&std::path::Path>, +) -> anyhow::Result { + let mut command = std::process::Command::new("dotslash"); + command.arg("--").arg("fetch").arg(dotslash_file); + if let Some(dotslash_cache) = dotslash_cache { + command.env("DOTSLASH_CACHE", dotslash_cache); + } + let output = command.output().with_context(|| { + format!( + "failed to run dotslash to fetch resource {}", + dotslash_file.display() + ) + })?; + ensure!( + output.status.success(), + "dotslash fetch failed for {}: {}", + dotslash_file.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + let fetched_path = String::from_utf8(output.stdout) + .context("dotslash fetch output was not utf8")? + .trim() + .to_string(); + ensure!(!fetched_path.is_empty(), "dotslash fetch output was empty"); + let fetched_path = PathBuf::from(fetched_path); + ensure!( + fetched_path.is_file(), + "dotslash returned non-file path: {}", + fetched_path.display() + ); + Ok(fetched_path) +} + /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 59e40c0160..98848e911c 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -566,7 +566,7 @@ fn message_input_texts(body: &Value, role: &str) -> Vec { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> { +async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> { skip_if_no_network!(Ok(())); let api_server = start_mock_server().await; @@ -589,7 +589,7 @@ async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> { "type": "conversation.item.added", "item": { "type": "message", - "role": "user", + "role": "assistant", "content": [{"type": "text", "text": "text from realtime"}] } }), @@ -633,6 +633,321 @@ async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.created", + "session": { "id": "sess_ignore_user_role" } + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "text", "text": "echoed local text"}] + } + }), + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + }), + ]]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_ignore_user_role" => Some(()), + _ => None, + }) + .await; + + let audio_out = tokio::time::timeout( + Duration::from_millis(500), + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) => Some(frame.clone()), + _ => None, + }), + ) + .await + .expect("timed out waiting for realtime audio after user-role conversation item"); + assert_eq!(audio_out.data, "AQID"); + + let unexpected_turn_started = tokio::time::timeout( + Duration::from_millis(200), + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::TurnStarted(_) => Some(()), + _ => None, + }), + ) + .await; + assert!(unexpected_turn_started.is_err()); + + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_audio() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_assistant_message( + "msg-1", + "assistant says hi", + )), + }, + StreamingSseChunk { + gate: Some(gate_completed_rx), + body: sse_event(responses::ev_completed("resp-1")), + }, + ]; + let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await; + + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.created", + "session": { "id": "sess_echo_guard" } + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "delegate now"}] + } + }), + ], + vec![ + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "user", + "content": [{"type": "text", "text": "assistant says hi"}] + } + }), + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + }), + ], + ]]) + .await; + + let mut builder = test_codex().with_model("gpt-5.1").with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_streaming_server(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_echo_guard" => Some(()), + _ => None, + }) + .await; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ConversationItemAdded(item), + }) => item + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now")) + .then_some(()), + _ => None, + }) + .await; + + let audio_out = tokio::time::timeout( + Duration::from_millis(500), + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) => Some(frame.clone()), + _ => None, + }), + ) + .await + .expect("timed out waiting for realtime audio after echoed user-role message"); + assert_eq!(audio_out.data, "AQID"); + + let completion = completions + .into_iter() + .next() + .expect("missing delegated turn completion"); + let _ = gate_completed_tx.send(()); + completion + .await + .expect("delegated turn request did not complete"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = api_server.requests().await; + assert_eq!(requests.len(), 1); + + realtime_server.shutdown().await; + api_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: Some(gate_completed_rx), + body: sse_event(responses::ev_completed("resp-1")), + }, + ]; + let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await; + + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.created", + "session": { "id": "sess_non_blocking" } + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "delegate now"}] + } + }), + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + }), + ]]]) + .await; + + let mut builder = test_codex().with_model("gpt-5.1").with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_streaming_server(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_non_blocking" => Some(()), + _ => None, + }) + .await; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ConversationItemAdded(item), + }) => item + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now")) + .then_some(()), + _ => None, + }) + .await; + + let audio_out = tokio::time::timeout( + Duration::from_millis(500), + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) => Some(frame.clone()), + _ => None, + }), + ) + .await + .expect("timed out waiting for realtime audio while delegated turn was still pending"); + assert_eq!(audio_out.data, "AQID"); + + let completion = completions + .into_iter() + .next() + .expect("missing delegated turn completion"); + let _ = gate_completed_tx.send(()); + completion + .await + .expect("delegated turn request did not complete"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + realtime_server.shutdown().await; + api_server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn inbound_realtime_text_steers_active_turn() -> Result<()> { skip_if_no_network!(Ok(())); @@ -791,3 +1106,121 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> { api_server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: Some(gate_completed_rx), + body: sse_event(responses::ev_completed("resp-1")), + }, + ]; + let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await; + + let delegated_text = "delegate from spawn transcript"; + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.created", + "session": { "id": "sess_spawn_transcript" } + }), + json!({ + "type": "conversation.item.added", + "item": { + "type": "spawn_transcript", + "seq": 1, + "full_user_transcript": delegated_text, + "delta_user_transcript": delegated_text, + "backend_prompt_messages": [{ + "role": "user", + "channel": null, + "content": delegated_text, + "content_type": "text" + }], + "transcript_source": "backend_prompt_messages" + } + }), + json!({ + "type": "response.output_audio.delta", + "delta": "AQID", + "sample_rate": 24000, + "num_channels": 1 + }), + ]]]) + .await; + + let mut builder = test_codex().with_model("gpt-5.1").with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_streaming_server(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) if session_id == "sess_spawn_transcript" => Some(()), + _ => None, + }) + .await; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ConversationItemAdded(item), + }) => (item.get("type").and_then(Value::as_str) == Some("spawn_transcript") + && item.get("delta_user_transcript").and_then(Value::as_str) == Some(delegated_text)) + .then_some(()), + _ => None, + }) + .await; + + let audio_out = tokio::time::timeout( + Duration::from_millis(500), + wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(frame), + }) => Some(frame.clone()), + _ => None, + }), + ) + .await + .expect("timed out waiting for realtime audio after spawn_transcript"); + assert_eq!(audio_out.data, "AQID"); + + let completion = completions + .into_iter() + .next() + .expect("missing delegated turn completion"); + let _ = gate_completed_tx.send(()); + completion + .await + .expect("delegated turn request did not complete"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = api_server.requests().await; + assert_eq!(requests.len(), 1); + let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request"); + let first_texts = message_input_texts(&first_body, "user"); + assert!(first_texts.iter().any(|text| text == delegated_text)); + + realtime_server.shutdown().await; + api_server.shutdown().await; + Ok(()) +} diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 96315e5c75..5d2c8e3d5c 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -237,40 +237,29 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let req = mock.single_request(); let body = req.body_json(); - let output_text = req - .function_call_output_content_and_success(call_id) - .and_then(|(content, _)| content) - .expect("output text present"); - assert_eq!(output_text, "attached local image path"); + assert!( + find_image_message(&body).is_none(), + "view_image tool should not inject a separate image message" + ); - let image_message = - find_image_message(&body).expect("pending input image message not included in request"); - let content_items = image_message - .get("content") + let function_output = req.function_call_output(call_id); + let output_items = function_output + .get("output") .and_then(Value::as_array) - .expect("image message has content array"); + .expect("function_call_output should be a content item array"); assert_eq!( - content_items.len(), + output_items.len(), 1, - "view_image should inject only the image content item (no tag/label text)" + "view_image should return only the image content item (no tag/label text)" ); assert_eq!( - content_items[0].get("type").and_then(Value::as_str), + output_items[0].get("type").and_then(Value::as_str), Some("input_image"), - "view_image should inject only an input_image content item" + "view_image should return only an input_image content item" ); - let image_url = image_message - .get("content") - .and_then(Value::as_array) - .and_then(|content| { - content.iter().find_map(|span| { - if span.get("type").and_then(Value::as_str) == Some("input_image") { - span.get("image_url").and_then(Value::as_str) - } else { - None - } - }) - }) + let image_url = output_items[0] + .get("image_url") + .and_then(Value::as_str) .expect("image_url present"); let (prefix, encoded) = image_url @@ -535,38 +524,36 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> request.inputs_of_type("input_image").is_empty(), "non-image file should not produce an input_image message" ); + let function_output = request.function_call_output(call_id); + let output_items = function_output + .get("output") + .and_then(Value::as_array) + .expect("function_call_output should be a content item array"); + assert_eq!( + output_items.len(), + 1, + "non-image placeholder should be returned as a single content item" + ); + assert_eq!( + output_items[0].get("type").and_then(Value::as_str), + Some("input_text"), + "non-image placeholder should be returned as input_text" + ); + let placeholder = output_items[0] + .get("text") + .and_then(Value::as_str) + .expect("placeholder text present"); - let placeholder = request - .inputs_of_type("message") - .iter() - .find_map(|item| { - let content = item.get("content").and_then(Value::as_array)?; - content.iter().find_map(|span| { - if span.get("type").and_then(Value::as_str) == Some("input_text") { - let text = span.get("text").and_then(Value::as_str)?; - if text.contains("Codex could not read the local image at") - && text.contains("unsupported MIME type `application/json`") - { - return Some(text.to_string()); - } - } - None - }) - }) - .expect("placeholder text found"); - + assert!( + placeholder.contains("Codex could not read the local image at") + && placeholder.contains("unsupported MIME type `application/json`"), + "placeholder should describe the unsupported file type: {placeholder}" + ); assert!( placeholder.contains(&abs_path.display().to_string()), "placeholder should mention path: {placeholder}" ); - let output_text = mock - .single_request() - .function_call_output_content_and_success(call_id) - .and_then(|(content, _)| content) - .expect("output text present"); - assert_eq!(output_text, "attached local image path"); - Ok(()) } diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 4d9d79270d..a6a0721b98 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "codex-exec-server" -version.workspace = true edition.workspace = true license.workspace = true +version.workspace = true [[bin]] name = "codex-execve-wrapper" @@ -19,6 +19,11 @@ path = "src/lib.rs" [lints] workspace = true +[package.metadata.cargo-shear] +# This appears to be due to #[derive(rmcp::schemars::JsonSchema)], which +# requires use of schemars via a macro that shear cannot detect. +ignored = ["schemars"] + [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } @@ -27,8 +32,7 @@ codex-core = { workspace = true } codex-execpolicy = { workspace = true } codex-protocol = { workspace = true } codex-shell-command = { workspace = true } -libc = { workspace = true } -path-absolutize = { workspace = true } +codex-shell-escalation = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "auth", "elicitation", @@ -42,24 +46,18 @@ rmcp = { workspace = true, default-features = false, features = [ "transport-streamable-http-server", "transport-io", ] } +schemars = { version = "1.2.1" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } shlex = { workspace = true } -socket2 = { workspace = true } -tokio = { workspace = true, features = [ - "io-std", - "macros", - "process", - "rt-multi-thread", - "signal", -] } -tokio-util = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] -codex-utils-cargo-bin = { workspace = true } codex-protocol = { workspace = true } +codex-utils-cargo-bin = { workspace = true } +core_test_support = { workspace = true } exec_server_test_support = { workspace = true } maplit = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 62f7bbccca..cd55b46ce2 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -1,11 +1,5 @@ #[cfg(unix)] -mod posix; +mod unix; #[cfg(unix)] -pub use posix::main_execve_wrapper; - -#[cfg(unix)] -pub use posix::main_mcp_server; - -#[cfg(unix)] -pub use posix::ExecResult; +pub use unix::*; diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/unix.rs similarity index 97% rename from codex-rs/exec-server/src/posix.rs rename to codex-rs/exec-server/src/unix.rs index 907ec14b68..0e0355443b 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/unix.rs @@ -67,21 +67,16 @@ use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; +use codex_shell_escalation as shell_escalation; use rmcp::ErrorData as McpError; use tokio::sync::RwLock; use tracing_subscriber::EnvFilter; use tracing_subscriber::{self}; -use crate::posix::mcp_escalation_policy::ExecPolicyOutcome; +use crate::unix::mcp_escalation_policy::ExecPolicyOutcome; -mod escalate_client; -mod escalate_protocol; -mod escalate_server; -mod escalation_policy; mod mcp; mod mcp_escalation_policy; -mod socket; -mod stopwatch; pub use mcp::ExecResult; @@ -165,7 +160,7 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> { .init(); let ExecveWrapperCli { file, argv } = ExecveWrapperCli::parse(); - let exit_code = escalate_client::run(file, argv).await?; + let exit_code = shell_escalation::run(file, argv).await?; std::process::exit(exit_code); } diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/unix/mcp.rs similarity index 82% rename from codex-rs/exec-server/src/posix/mcp.rs rename to codex-rs/exec-server/src/unix/mcp.rs index 5319a1f916..547d055c14 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/unix/mcp.rs @@ -9,6 +9,8 @@ use codex_core::MCP_SANDBOX_STATE_METHOD; use codex_core::SandboxState; use codex_execpolicy::Policy; use codex_protocol::protocol::SandboxPolicy; +use codex_shell_escalation::EscalationPolicyFactory; +use codex_shell_escalation::run_escalate_server; use rmcp::ErrorData as McpError; use rmcp::RoleServer; use rmcp::ServerHandler; @@ -18,7 +20,6 @@ use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CustomRequest; use rmcp::model::CustomResult; use rmcp::model::*; -use rmcp::schemars; use rmcp::service::RequestContext; use rmcp::service::RunningService; use rmcp::tool; @@ -28,10 +29,7 @@ use rmcp::transport::stdio; use serde_json::json; use tokio::sync::RwLock; -use crate::posix::escalate_server::EscalateServer; -use crate::posix::escalate_server::{self}; -use crate::posix::mcp_escalation_policy::McpEscalationPolicy; -use crate::posix::stopwatch::Stopwatch; +use crate::unix::mcp_escalation_policy::McpEscalationPolicy; /// Path to our patched bash. const CODEX_BASH_PATH_ENV_VAR: &str = "CODEX_BASH_PATH"; @@ -44,19 +42,7 @@ pub(crate) fn get_bash_path() -> Result { .context(format!("{CODEX_BASH_PATH_ENV_VAR} must be set")) } -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct ExecParams { - /// The bash string to execute. - pub command: String, - /// The working directory to execute the command in. Must be an absolute path. - pub workdir: String, - /// The timeout for the command in milliseconds. - pub timeout_ms: Option, - /// Launch Bash with -lc instead of -c: defaults to true. - pub login: Option, -} - -#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct ExecResult { pub exit_code: i32, pub output: String, @@ -64,8 +50,8 @@ pub struct ExecResult { pub timed_out: bool, } -impl From for ExecResult { - fn from(result: escalate_server::ExecResult) -> Self { +impl From for ExecResult { + fn from(result: codex_shell_escalation::ExecResult) -> Self { Self { exit_code: result.exit_code, output: result.output, @@ -85,6 +71,51 @@ pub struct ExecTool { sandbox_state: Arc>>, } +#[derive(Debug, serde::Serialize, serde::Deserialize, rmcp::schemars::JsonSchema)] +pub struct ExecParams { + /// The bash string to execute. + pub command: String, + /// The working directory to execute the command in. Must be an absolute path. + pub workdir: String, + /// The timeout for the command in milliseconds. + pub timeout_ms: Option, + /// Launch Bash with -lc instead of -c: defaults to true. + pub login: Option, +} + +impl From for codex_shell_escalation::ExecParams { + fn from(inner: ExecParams) -> Self { + Self { + command: inner.command, + workdir: inner.workdir, + timeout_ms: inner.timeout_ms, + login: inner.login, + } + } +} + +struct McpEscalationPolicyFactory { + context: RequestContext, + preserve_program_paths: bool, +} + +impl EscalationPolicyFactory for McpEscalationPolicyFactory { + type Policy = McpEscalationPolicy; + + fn create_policy( + &self, + policy: Arc>, + stopwatch: codex_shell_escalation::Stopwatch, + ) -> Self::Policy { + McpEscalationPolicy::new( + policy, + self.context.clone(), + stopwatch, + self.preserve_program_paths, + ) + } +} + #[tool_router] impl ExecTool { pub fn new( @@ -115,8 +146,6 @@ impl ExecTool { .timeout_ms .unwrap_or(codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS), ); - let stopwatch = Stopwatch::new(effective_timeout); - let cancel_token = stopwatch.cancellation_token(); let sandbox_state = self.sandbox_state .read() @@ -128,21 +157,20 @@ impl ExecTool { sandbox_cwd: PathBuf::from(¶ms.workdir), use_linux_sandbox_bwrap: false, }); - let escalate_server = EscalateServer::new( - self.bash_path.clone(), - self.execve_wrapper.clone(), - McpEscalationPolicy::new( - self.policy.clone(), + let result = run_escalate_server( + params.into(), + &sandbox_state, + &self.bash_path, + &self.execve_wrapper, + self.policy.clone(), + McpEscalationPolicyFactory { context, - stopwatch.clone(), - self.preserve_program_paths, - ), - ); - - let result = escalate_server - .exec(params, cancel_token, &sandbox_state) - .await - .map_err(|e| McpError::internal_error(e.to_string(), None))?; + preserve_program_paths: self.preserve_program_paths, + }, + effective_timeout, + ) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None))?; Ok(CallToolResult::success(vec![Content::json( ExecResult::from(result), )?])) @@ -242,7 +270,7 @@ mod tests { /// `timeout_ms` fields are optional. #[test] fn exec_params_json_schema_matches_expected() { - let schema = schemars::schema_for!(ExecParams); + let schema = rmcp::schemars::schema_for!(ExecParams); let actual = serde_json::to_value(schema).expect("schema should serialize"); assert_eq!( diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/unix/mcp_escalation_policy.rs similarity index 92% rename from codex-rs/exec-server/src/posix/mcp_escalation_policy.rs rename to codex-rs/exec-server/src/unix/mcp_escalation_policy.rs index 5e6b97f1ad..9863818261 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/unix/mcp_escalation_policy.rs @@ -2,6 +2,9 @@ use std::path::Path; use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Policy; +use codex_shell_escalation::EscalateAction; +use codex_shell_escalation::EscalationPolicy; +use codex_shell_escalation::Stopwatch; use rmcp::ErrorData as McpError; use rmcp::RoleServer; use rmcp::model::CreateElicitationRequestParams; @@ -9,10 +12,7 @@ use rmcp::model::CreateElicitationResult; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::service::RequestContext; - -use crate::posix::escalate_protocol::EscalateAction; -use crate::posix::escalation_policy::EscalationPolicy; -use crate::posix::stopwatch::Stopwatch; +use shlex::try_join; use std::sync::Arc; use tokio::sync::RwLock; @@ -59,7 +59,7 @@ impl McpEscalationPolicy { workdir: &Path, context: RequestContext, ) -> Result { - let args = shlex::try_join(argv.iter().skip(1).map(String::as_str)).unwrap_or_default(); + let args = try_join(argv.iter().skip(1).map(String::as_str)).unwrap_or_default(); let command = if args.is_empty() { file.display().to_string() } else { @@ -104,10 +104,10 @@ impl EscalationPolicy for McpEscalationPolicy { file: &Path, argv: &[String], workdir: &Path, - ) -> Result { + ) -> anyhow::Result { let policy = self.policy.read().await; let outcome = - crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; + crate::unix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; let action = match outcome { ExecPolicyOutcome::Allow { sandbox_permissions, diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs index 32f30963ab..865001ac6f 100644 --- a/codex-rs/exec-server/tests/suite/accept_elicitation.rs +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -61,15 +61,9 @@ prefix_rule( /// Verify the same prompt/escalation flow works when the server is launched /// with a patched zsh binary. /// -/// Set CODEX_TEST_ZSH_PATH to enable this test locally or in CI. +/// The suite resolves `tests/suite/zsh` via DotSlash on first use. #[tokio::test(flavor = "current_thread")] async fn accept_elicitation_for_prompt_rule_with_zsh() -> Result<()> { - let Some(zsh_path) = std::env::var_os("CODEX_TEST_ZSH_PATH") else { - eprintln!("skipping zsh test: CODEX_TEST_ZSH_PATH is not set"); - return Ok(()); - }; - let zsh_path = PathBuf::from(zsh_path); - let codex_home = TempDir::new()?; write_default_execpolicy( r#" @@ -87,6 +81,11 @@ prefix_rule( .await?; let dotslash_cache_temp_dir = TempDir::new()?; let dotslash_cache = dotslash_cache_temp_dir.path(); + let zsh_path = resolve_test_zsh_path(dotslash_cache).await?; + eprintln!( + "using zsh path for exec-server test: {}", + zsh_path.display() + ); let transport = create_transport_with_shell_path(codex_home.as_ref(), dotslash_cache, &zsh_path).await?; run_accept_elicitation_for_prompt_rule_with_transport(transport).await @@ -95,13 +94,13 @@ prefix_rule( async fn run_accept_elicitation_for_prompt_rule_with_transport( transport: rmcp::transport::TokioChildProcess, ) -> Result<()> { - // Create an MCP client that approves expected elicitation messages. + // Create an MCP client that approves the expected elicitation message. let project_root = TempDir::new()?; let project_root_path = project_root.path().canonicalize().unwrap(); let git_path = resolve_git_path(USE_LOGIN_SHELL).await?; + let git_init_command = format!("{git_path} init --quiet ."); let expected_elicitation_message = format!( - "Allow agent to run `{} init .` in `{}`?", - git_path, + "Allow agent to run `{git_path} init --quiet .` in `{}`?", project_root_path.display() ); let elicitation_requests: Arc>> = Default::default(); @@ -142,7 +141,7 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport( arguments: Some(object(json!( { "login": USE_LOGIN_SHELL, - "command": "git init .", + "command": git_init_command, "workdir": project_root_path.to_string_lossy(), } ))), @@ -157,15 +156,11 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport( 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. + // `git init --quiet` is expected to suppress the usual initialization + // banner, so assert on success and filesystem effects instead of output. assert!( - output.contains(&git_init_succeeded), - "expected output `{output}` to contain `{git_init_succeeded}`" + output.is_empty(), + "expected no output from `git init --quiet .`, got `{output}`" ); assert_eq!(exit_code, 0, "command should succeed"); assert_eq!(is_error, Some(false), "command should succeed"); @@ -192,6 +187,12 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport( Ok(()) } +async fn resolve_test_zsh_path(dotslash_cache: &std::path::Path) -> Result { + let dotslash_zsh = codex_utils_cargo_bin::find_resource!("tests/suite/zsh")?; + core_test_support::fetch_dotslash_file(&dotslash_zsh, Some(dotslash_cache)) + .with_context(|| format!("failed to fetch test zsh from {}", dotslash_zsh.display())) +} + fn ensure_codex_cli() -> Result { let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?; diff --git a/codex-rs/exec-server/tests/suite/zsh b/codex-rs/exec-server/tests/suite/zsh new file mode 100755 index 0000000000..34295b5c63 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/zsh @@ -0,0 +1,72 @@ +#!/usr/bin/env dotslash + +// This is the patched zsh fork built by +// `.github/workflows/shell-tool-mcp.yml` for the shell-tool-mcp package. +// Fetching the prebuilt version via DotSlash makes it easier to write +// integration tests that exercise the zsh fork behavior in exec-server tests. +// +// 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-zsh", + "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": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/aarch64-apple-darwin/zsh/macos-15/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + "linux-x86_64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/x86_64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + "linux-aarch64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/aarch64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + } +} diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 05845e00fa..c402371607 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -96,7 +96,12 @@ pub struct Cli { pub json: bool, /// Specifies file where the last message from the agent should be written. - #[arg(long = "output-last-message", short = 'o', value_name = "FILE")] + #[arg( + long = "output-last-message", + short = 'o', + value_name = "FILE", + global = true + )] pub last_message_file: Option, /// Initial instructions for the agent. If not provided as an argument (or @@ -283,4 +288,27 @@ mod tests { }); assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); } + + #[test] + fn resume_accepts_output_last_message_flag_after_subcommand() { + const PROMPT: &str = "echo resume-with-output-file"; + let cli = Cli::parse_from([ + "codex-exec", + "resume", + "session-123", + "-o", + "/tmp/resume-output.md", + PROMPT, + ]); + + assert_eq!( + cli.last_message_file, + Some(PathBuf::from("/tmp/resume-output.md")) + ); + let Some(Command::Resume(args)) = cli.command else { + panic!("expected resume command"); + }; + assert_eq!(args.session_id.as_deref(), Some("session-123")); + assert_eq!(args.prompt.as_deref(), Some(PROMPT)); + } } diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 4169c60dd1..89e125bf8d 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -4,11 +4,7 @@ use codex_utils_cargo_bin::find_resource; use core_test_support::test_codex_exec::test_codex_exec; use pretty_assertions::assert_eq; use serde_json::Value; -use std::fs::FileTimes; -use std::fs::OpenOptions; use std::string::ToString; -use std::time::Duration; -use std::time::SystemTime; use tempfile::TempDir; use uuid::Uuid; use walkdir::WalkDir; @@ -257,21 +253,26 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { .success(); let sessions_dir = test.home_path().join("sessions"); - let path_a = find_session_file_containing_marker(&sessions_dir, &marker_a) + find_session_file_containing_marker(&sessions_dir, &marker_a) .expect("no session file found for marker_a"); let path_b = find_session_file_containing_marker(&sessions_dir, &marker_b) .expect("no session file found for marker_b"); - // Files are ordered by `updated_at`, then by `uuid`. - // We mutate the mtimes to ensure file_b is the newest file. - let file_a = OpenOptions::new().write(true).open(&path_a)?; - file_a.set_times( - FileTimes::new().set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1)), - )?; - let file_b = OpenOptions::new().write(true).open(&path_b)?; - file_b.set_times( - FileTimes::new().set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(2)), - )?; + // Make thread B deterministically newest according to rollout metadata. + let session_id_b = extract_conversation_id(&path_b); + let marker_b_touch = format!("resume-cwd-b-touch-{}", Uuid::new_v4()); + let prompt_b_touch = format!("echo {marker_b_touch}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(dir_b.path()) + .arg("resume") + .arg(&session_id_b) + .arg(&prompt_b_touch) + .assert() + .success(); let marker_b2 = format!("resume-cwd-b-2-{}", Uuid::new_v4()); let prompt_b2 = format!("echo {marker_b2}"); @@ -312,8 +313,8 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> { let resumed_path_cwd = find_session_file_containing_marker(&sessions_dir, &marker_a2) .expect("no resumed session file containing marker_a2"); assert_eq!( - resumed_path_cwd, path_a, - "resume --last should prefer sessions from the same cwd" + resumed_path_cwd, path_b, + "resume --last should prefer sessions whose latest turn context matches the current cwd" ); Ok(()) diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 53b4ad3601..f0b3824230 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -431,7 +431,7 @@ fn build_authorize_url( ("redirect_uri".to_string(), redirect_uri.to_string()), ( "scope".to_string(), - "openid profile email offline_access".to_string(), + "openid profile email offline_access api.model.audio.request".to_string(), ), ( "code_challenge".to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a8b7ade166..de7c16e677 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1961,6 +1961,9 @@ impl SessionSource { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => { agent_nickname.clone() } + SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => { + Some("morpheus".to_string()) + } _ => None, } } @@ -1970,6 +1973,9 @@ impl SessionSource { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { agent_role.clone() } + SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => { + Some("memory builder".to_string()) + } _ => None, } } diff --git a/codex-rs/shell-escalation/BUILD.bazel b/codex-rs/shell-escalation/BUILD.bazel new file mode 100644 index 0000000000..5092f33324 --- /dev/null +++ b/codex-rs/shell-escalation/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "shell-escalation", + crate_name = "codex_shell_escalation", +) diff --git a/codex-rs/shell-escalation/Cargo.toml b/codex-rs/shell-escalation/Cargo.toml new file mode 100644 index 0000000000..49b8f10bd3 --- /dev/null +++ b/codex-rs/shell-escalation/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "codex-shell-escalation" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +codex-core = { workspace = true } +codex-execpolicy = { workspace = true } +codex-protocol = { workspace = true } +libc = { workspace = true } +serde_json = { workspace = true } +path-absolutize = { workspace = true } +serde = { workspace = true, features = ["derive"] } +socket2 = { workspace = true } +tokio = { workspace = true, features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", +] } +tokio-util = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/shell-escalation/src/lib.rs b/codex-rs/shell-escalation/src/lib.rs new file mode 100644 index 0000000000..555d0f89e0 --- /dev/null +++ b/codex-rs/shell-escalation/src/lib.rs @@ -0,0 +1,21 @@ +#[cfg(unix)] +mod unix { + mod escalate_client; + mod escalate_protocol; + mod escalate_server; + mod escalation_policy; + mod socket; + mod stopwatch; + + pub use self::escalate_client::run; + pub use self::escalate_protocol::EscalateAction; + pub use self::escalate_server::EscalationPolicyFactory; + pub use self::escalate_server::ExecParams; + pub use self::escalate_server::ExecResult; + pub use self::escalate_server::run_escalate_server; + pub use self::escalation_policy::EscalationPolicy; + pub use self::stopwatch::Stopwatch; +} + +#[cfg(unix)] +pub use unix::*; diff --git a/codex-rs/shell-escalation/src/unix/core_shell_escalation.rs b/codex-rs/shell-escalation/src/unix/core_shell_escalation.rs new file mode 100644 index 0000000000..0be7af28fa --- /dev/null +++ b/codex-rs/shell-escalation/src/unix/core_shell_escalation.rs @@ -0,0 +1,71 @@ +use async_trait::async_trait; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::escalate_protocol::EscalateAction; +use crate::escalation_policy::EscalationPolicy; +use crate::stopwatch::Stopwatch; +use crate::unix::escalate_server::EscalationPolicyFactory; +use codex_execpolicy::Policy; + +#[async_trait] +pub trait ShellActionProvider: Send + Sync { + async fn determine_action( + &self, + file: &Path, + argv: &[String], + workdir: &Path, + stopwatch: &Stopwatch, + ) -> anyhow::Result; +} + +#[derive(Clone)] +pub struct ShellPolicyFactory { + provider: Arc, +} + +impl ShellPolicyFactory { + pub fn new

(provider: P) -> Self + where + P: ShellActionProvider + 'static, + { + Self { + provider: Arc::new(provider), + } + } + + pub fn with_provider(provider: Arc) -> Self { + Self { provider } + } +} + +struct ShellEscalationPolicy { + provider: Arc, + stopwatch: Stopwatch, +} + +#[async_trait] +impl EscalationPolicy for ShellEscalationPolicy { + async fn determine_action( + &self, + file: &Path, + argv: &[String], + workdir: &Path, + ) -> anyhow::Result { + self.provider + .determine_action(file, argv, workdir, &self.stopwatch) + .await + } +} + +impl EscalationPolicyFactory for ShellPolicyFactory { + type Policy = ShellEscalationPolicy; + + fn create_policy(&self, _policy: Arc>, stopwatch: Stopwatch) -> Self::Policy { + ShellEscalationPolicy { + provider: Arc::clone(&self.provider), + stopwatch, + } + } +} diff --git a/codex-rs/exec-server/src/posix/escalate_client.rs b/codex-rs/shell-escalation/src/unix/escalate_client.rs similarity index 85% rename from codex-rs/exec-server/src/posix/escalate_client.rs rename to codex-rs/shell-escalation/src/unix/escalate_client.rs index 1cb0c5908d..17d8e6577b 100644 --- a/codex-rs/exec-server/src/posix/escalate_client.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_client.rs @@ -5,16 +5,16 @@ use std::os::fd::OwnedFd; use anyhow::Context as _; -use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; -use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; -use crate::posix::escalate_protocol::EscalateAction; -use crate::posix::escalate_protocol::EscalateRequest; -use crate::posix::escalate_protocol::EscalateResponse; -use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; -use crate::posix::escalate_protocol::SuperExecMessage; -use crate::posix::escalate_protocol::SuperExecResult; -use crate::posix::socket::AsyncDatagramSocket; -use crate::posix::socket::AsyncSocket; +use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; +use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; +use crate::unix::escalate_protocol::EscalateAction; +use crate::unix::escalate_protocol::EscalateRequest; +use crate::unix::escalate_protocol::EscalateResponse; +use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; +use crate::unix::escalate_protocol::SuperExecMessage; +use crate::unix::escalate_protocol::SuperExecResult; +use crate::unix::socket::AsyncDatagramSocket; +use crate::unix::socket::AsyncSocket; fn get_escalate_client() -> anyhow::Result { // TODO: we should defensively require only calling this once, since AsyncSocket will take ownership of the fd. @@ -27,7 +27,7 @@ fn get_escalate_client() -> anyhow::Result { Ok(unsafe { AsyncDatagramSocket::from_raw_fd(client_fd) }?) } -pub(crate) async fn run(file: String, argv: Vec) -> anyhow::Result { +pub async fn run(file: String, argv: Vec) -> anyhow::Result { let handshake_client = get_escalate_client()?; let (server, client) = AsyncSocket::pair()?; const HANDSHAKE_MESSAGE: [u8; 1] = [0]; diff --git a/codex-rs/exec-server/src/posix/escalate_protocol.rs b/codex-rs/shell-escalation/src/unix/escalate_protocol.rs similarity index 66% rename from codex-rs/exec-server/src/posix/escalate_protocol.rs rename to codex-rs/shell-escalation/src/unix/escalate_protocol.rs index 7245f88fbe..3bf2f712df 100644 --- a/codex-rs/exec-server/src/posix/escalate_protocol.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_protocol.rs @@ -6,33 +6,33 @@ use serde::Deserialize; use serde::Serialize; /// 'exec-server escalate' reads this to find the inherited FD for the escalate socket. -pub(super) const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET"; +pub const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET"; /// Patched shells use this to wrap exec() calls. -pub(super) const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER"; +pub const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER"; /// Compatibility alias for older patched bash builds. -pub(super) const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER"; +pub const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER"; /// The client sends this to the server to request an exec() call. #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub(super) struct EscalateRequest { +pub struct EscalateRequest { /// The absolute path to the executable to run, i.e. the first arg to exec. - pub(super) file: PathBuf, + pub file: PathBuf, /// The argv, including the program name (argv[0]). - pub(super) argv: Vec, - pub(super) workdir: PathBuf, - pub(super) env: HashMap, + pub argv: Vec, + pub workdir: PathBuf, + pub env: HashMap, } /// The server sends this to the client to respond to an exec() request. #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub(super) struct EscalateResponse { - pub(super) action: EscalateAction, +pub struct EscalateResponse { + pub action: EscalateAction, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub(super) enum EscalateAction { +pub enum EscalateAction { /// The command should be run directly by the client. Run, /// The command should be escalated to the server for execution. @@ -43,12 +43,12 @@ pub(super) enum EscalateAction { /// The client sends this to the server to forward its open FDs. #[derive(Clone, Serialize, Deserialize, Debug)] -pub(super) struct SuperExecMessage { - pub(super) fds: Vec, +pub struct SuperExecMessage { + pub fds: Vec, } /// The server responds when the exec()'d command has exited. #[derive(Clone, Serialize, Deserialize, Debug)] -pub(super) struct SuperExecResult { - pub(super) exit_code: i32, +pub struct SuperExecResult { + pub exit_code: i32, } diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs similarity index 76% rename from codex-rs/exec-server/src/posix/escalate_server.rs rename to codex-rs/shell-escalation/src/unix/escalate_server.rs index de4bdfc2cd..0ee5fc27c4 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -1,35 +1,54 @@ use std::collections::HashMap; use std::os::fd::AsRawFd; +use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use anyhow::Context as _; -use path_absolutize::Absolutize as _; - use codex_core::SandboxState; -use codex_core::exec::process_exec_tool_call; -use codex_core::sandboxing::SandboxPermissions; -use codex_protocol::config_types::WindowsSandboxLevel; +use codex_execpolicy::Policy; +use path_absolutize::Absolutize as _; use tokio::process::Command; +use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; -use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; -use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; -use crate::posix::escalate_protocol::EscalateAction; -use crate::posix::escalate_protocol::EscalateRequest; -use crate::posix::escalate_protocol::EscalateResponse; -use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; -use crate::posix::escalate_protocol::SuperExecMessage; -use crate::posix::escalate_protocol::SuperExecResult; -use crate::posix::escalation_policy::EscalationPolicy; -use crate::posix::mcp::ExecParams; -use crate::posix::socket::AsyncDatagramSocket; -use crate::posix::socket::AsyncSocket; -use codex_core::exec::ExecExpiration; +use crate::unix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; +use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; +use crate::unix::escalate_protocol::EscalateAction; +use crate::unix::escalate_protocol::EscalateRequest; +use crate::unix::escalate_protocol::EscalateResponse; +use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; +use crate::unix::escalate_protocol::SuperExecMessage; +use crate::unix::escalate_protocol::SuperExecResult; +use crate::unix::escalation_policy::EscalationPolicy; +use crate::unix::socket::AsyncDatagramSocket; +use crate::unix::socket::AsyncSocket; +use crate::unix::stopwatch::Stopwatch; -pub(crate) struct EscalateServer { +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct ExecParams { + /// The bash string to execute. + pub command: String, + /// The working directory to execute the command in. Must be an absolute path. + pub workdir: String, + /// The timeout for the command in milliseconds. + pub timeout_ms: Option, + /// Launch Bash with -lc instead of -c: defaults to true. + pub login: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ExecResult { + pub exit_code: i32, + pub output: String, + pub duration: Duration, + pub timed_out: bool, +} + +#[allow(clippy::module_name_repetitions)] +pub struct EscalateServer { bash_path: PathBuf, execve_wrapper: PathBuf, policy: Arc, @@ -78,7 +97,7 @@ impl EscalateServer { timeout_ms: _, login, } = params; - let result = process_exec_tool_call( + let result = codex_core::exec::process_exec_tool_call( codex_core::exec::ExecParams { command: vec![ self.bash_path.to_string_lossy().to_string(), @@ -90,11 +109,11 @@ impl EscalateServer { command, ], cwd: PathBuf::from(&workdir), - expiration: ExecExpiration::Cancellation(cancel_rx), + expiration: codex_core::exec::ExecExpiration::Cancellation(cancel_rx), env, network: None, - sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, justification: None, arg0: None, }, @@ -106,16 +125,45 @@ impl EscalateServer { ) .await?; escalate_task.abort(); - let result = ExecResult { + + Ok(ExecResult { exit_code: result.exit_code, output: result.aggregated_output.text, duration: result.duration, timed_out: result.timed_out, - }; - Ok(result) + }) } } +/// Factory for creating escalation policy instances for a single shell run. +pub trait EscalationPolicyFactory { + type Policy: EscalationPolicy + Send + Sync + 'static; + + fn create_policy(&self, policy: Arc>, stopwatch: Stopwatch) -> Self::Policy; +} + +pub async fn run_escalate_server( + exec_params: ExecParams, + sandbox_state: &SandboxState, + shell_program: impl AsRef, + execve_wrapper: impl AsRef, + policy: Arc>, + escalation_policy_factory: impl EscalationPolicyFactory, + effective_timeout: Duration, +) -> anyhow::Result { + let stopwatch = Stopwatch::new(effective_timeout); + let cancel_token = stopwatch.cancellation_token(); + let escalate_server = EscalateServer::new( + shell_program.as_ref().to_path_buf(), + execve_wrapper.as_ref().to_path_buf(), + escalation_policy_factory.create_policy(policy, stopwatch), + ); + + escalate_server + .exec(exec_params, cancel_token, sandbox_state) + .await +} + async fn escalate_task( socket: AsyncDatagramSocket, policy: Arc, @@ -136,14 +184,6 @@ async fn escalate_task( } } -#[derive(Debug)] -pub(crate) struct ExecResult { - pub(crate) exit_code: i32, - pub(crate) output: String, - pub(crate) duration: Duration, - pub(crate) timed_out: bool, -} - async fn handle_escalate_session_with_policy( socket: AsyncSocket, policy: Arc, @@ -158,7 +198,8 @@ async fn handle_escalate_session_with_policy( let workdir = PathBuf::from(&workdir).absolutize()?.into_owned(); let action = policy .determine_action(file.as_path(), &argv, &workdir) - .await?; + .await + .context("failed to determine escalation action")?; tracing::debug!("decided {action:?} for {file:?} {argv:?} {workdir:?}"); @@ -253,7 +294,7 @@ mod tests { _file: &Path, _argv: &[String], _workdir: &Path, - ) -> Result { + ) -> anyhow::Result { Ok(self.action.clone()) } } diff --git a/codex-rs/exec-server/src/posix/escalation_policy.rs b/codex-rs/shell-escalation/src/unix/escalation_policy.rs similarity index 62% rename from codex-rs/exec-server/src/posix/escalation_policy.rs rename to codex-rs/shell-escalation/src/unix/escalation_policy.rs index a7fcc4f627..09e74e2719 100644 --- a/codex-rs/exec-server/src/posix/escalation_policy.rs +++ b/codex-rs/shell-escalation/src/unix/escalation_policy.rs @@ -1,14 +1,14 @@ use std::path::Path; -use crate::posix::escalate_protocol::EscalateAction; +use crate::unix::escalate_protocol::EscalateAction; /// Decides what action to take in response to an execve request from a client. #[async_trait::async_trait] -pub(crate) trait EscalationPolicy: Send + Sync { +pub trait EscalationPolicy: Send + Sync { async fn determine_action( &self, file: &Path, argv: &[String], workdir: &Path, - ) -> Result; + ) -> anyhow::Result; } diff --git a/codex-rs/shell-escalation/src/unix/mod.rs b/codex-rs/shell-escalation/src/unix/mod.rs new file mode 100644 index 0000000000..0ae7941da2 --- /dev/null +++ b/codex-rs/shell-escalation/src/unix/mod.rs @@ -0,0 +1,7 @@ +pub mod escalate_client; +pub mod escalate_protocol; +pub mod escalate_server; +pub mod escalation_policy; +pub mod socket; +pub mod core_shell_escalation; +pub mod stopwatch; diff --git a/codex-rs/exec-server/src/posix/socket.rs b/codex-rs/shell-escalation/src/unix/socket.rs similarity index 100% rename from codex-rs/exec-server/src/posix/socket.rs rename to codex-rs/shell-escalation/src/unix/socket.rs diff --git a/codex-rs/exec-server/src/posix/stopwatch.rs b/codex-rs/shell-escalation/src/unix/stopwatch.rs similarity index 96% rename from codex-rs/exec-server/src/posix/stopwatch.rs rename to codex-rs/shell-escalation/src/unix/stopwatch.rs index de29a45685..f564434813 100644 --- a/codex-rs/exec-server/src/posix/stopwatch.rs +++ b/codex-rs/shell-escalation/src/unix/stopwatch.rs @@ -8,7 +8,7 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; #[derive(Clone, Debug)] -pub(crate) struct Stopwatch { +pub struct Stopwatch { limit: Duration, inner: Arc>, notify: Arc, @@ -22,7 +22,7 @@ struct StopwatchState { } impl Stopwatch { - pub(crate) fn new(limit: Duration) -> Self { + pub fn new(limit: Duration) -> Self { Self { inner: Arc::new(Mutex::new(StopwatchState { elapsed: Duration::ZERO, @@ -34,7 +34,7 @@ impl Stopwatch { } } - pub(crate) fn cancellation_token(&self) -> CancellationToken { + pub fn cancellation_token(&self) -> CancellationToken { let limit = self.limit; let token = CancellationToken::new(); let cancel = token.clone(); @@ -80,7 +80,7 @@ impl Stopwatch { /// resumes automatically when the future completes. Nested/overlapping /// calls are reference-counted so the stopwatch only resumes when every /// pause is lifted. - pub(crate) async fn pause_for(&self, fut: F) -> T + pub async fn pause_for(&self, fut: F) -> T where F: Future, { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1b0ed9771e..1a87314e98 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -13,10 +13,12 @@ name = "codex_tui" path = "src/lib.rs" [features] +default = ["voice-input"] # Enable vt100-based tests (emulator) when running with `--features vt100-tests`. vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] +voice-input = ["dep:cpal", "dep:hound"] [lints] workspace = true @@ -68,7 +70,7 @@ ratatui = { workspace = true, features = [ ] } ratatui-macros = { workspace = true } regex-lite = { workspace = true } -reqwest = { version = "0.12", features = ["json"] } +reqwest = { workspace = true, features = ["json", "multipart"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } @@ -104,6 +106,10 @@ uuid = { workspace = true } codex-windows-sandbox = { workspace = true } tokio-util = { workspace = true, features = ["time"] } +[target.'cfg(not(target_os = "linux"))'.dependencies] +cpal = { version = "0.15", optional = true } +hound = { version = "3.5", optional = true } + [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e080b8cde0..e554f6a605 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -23,6 +23,10 @@ use crate::history_cell::UpdateAvailableHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; use crate::model_migration::run_model_migration_prompt; +use crate::multi_agents::AgentPickerThreadEntry; +use crate::multi_agents::agent_picker_status_dot_spans; +use crate::multi_agents::format_agent_picker_item_name; +use crate::multi_agents::sort_agent_picker_threads; use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; @@ -576,6 +580,7 @@ pub(crate) struct App { windows_sandbox: WindowsSandboxState, thread_event_channels: HashMap, + agent_picker_threads: HashMap, active_thread_id: Option, active_thread_rx: Option>, primary_thread_id: Option, @@ -699,27 +704,18 @@ impl App { self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) } - fn clear_terminal_ui(&mut self, tui: &mut tui::Tui) -> Result<()> { + fn clear_terminal_ui(&mut self, tui: &mut tui::Tui, redraw_header: bool) -> Result<()> { let is_alt_screen_active = tui.is_alt_screen_active(); - let use_apple_terminal_clear_workaround = !is_alt_screen_active - && matches!( - codex_core::terminal::terminal_info().name, - codex_core::terminal::TerminalName::AppleTerminal - ); // Drop queued history insertions so stale transcript lines cannot be flushed after /clear. tui.clear_pending_history_lines(); if is_alt_screen_active { tui.terminal.clear_visible_screen()?; - } else if use_apple_terminal_clear_workaround { - // Terminal.app can leave mixed old/new glyphs behind when we purge + clear. - // Use a stricter ANSI reset, then redraw only a fresh session header box instead of - // replaying the initialization transcript preamble. - tui.terminal.clear_scrollback_and_visible_screen_ansi()?; } else { - tui.terminal.clear_scrollback()?; - tui.terminal.clear_visible_screen()?; + // Some terminals (Terminal.app, Warp) do not reliably drop scrollback when purge and + // clear are emitted as separate backend commands. Prefer a single ANSI sequence. + tui.terminal.clear_scrollback_and_visible_screen_ansi()?; } let mut area = tui.terminal.viewport_area; @@ -731,11 +727,13 @@ impl App { } self.has_emitted_history_lines = false; - let width = tui.terminal.last_known_screen_size.width; - let header_lines = self.clear_ui_header_lines(width); - if !header_lines.is_empty() { - tui.insert_history_lines(header_lines); - self.has_emitted_history_lines = true; + if redraw_header { + let width = tui.terminal.last_known_screen_size.width; + let header_lines = self.clear_ui_header_lines(width); + if !header_lines.is_empty() { + tui.insert_history_lines(header_lines); + self.has_emitted_history_lines = true; + } } Ok(()) } @@ -868,50 +866,55 @@ impl App { async fn open_agent_picker(&mut self) { let thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); - let mut agent_threads = Vec::new(); for thread_id in thread_ids { match self.server.get_thread(thread_id).await { Ok(thread) => { let session_source = thread.config_snapshot().await.session_source; - agent_threads.push(( + self.upsert_agent_picker_thread( thread_id, session_source.get_nickname(), session_source.get_agent_role(), - )); + false, + ); } Err(_) => { - self.thread_event_channels.remove(&thread_id); + self.mark_agent_picker_thread_closed(thread_id); } } } - if agent_threads.is_empty() { + if self.agent_picker_threads.is_empty() { self.chat_widget .add_info_message("No agents available yet.".to_string(), None); return; } - agent_threads.sort_by(|(left, ..), (right, ..)| left.to_string().cmp(&right.to_string())); + let mut agent_threads: Vec<(ThreadId, AgentPickerThreadEntry)> = self + .agent_picker_threads + .iter() + .map(|(thread_id, entry)| (*thread_id, entry.clone())) + .collect(); + sort_agent_picker_threads(&mut agent_threads); let mut initial_selected_idx = None; let items: Vec = agent_threads .iter() .enumerate() - .map(|(idx, (thread_id, agent_nickname, agent_role))| { + .map(|(idx, (thread_id, entry))| { if self.active_thread_id == Some(*thread_id) { initial_selected_idx = Some(idx); } let id = *thread_id; let is_primary = self.primary_thread_id == Some(*thread_id); let name = format_agent_picker_item_name( - *thread_id, - agent_nickname.as_deref(), - agent_role.as_deref(), + entry.agent_nickname.as_deref(), + entry.agent_role.as_deref(), is_primary, ); let uuid = thread_id.to_string(); SelectionItem { name: name.clone(), + name_prefix_spans: agent_picker_status_dot_spans(entry.is_closed), description: Some(uuid.clone()), is_current: self.active_thread_id == Some(*thread_id), actions: vec![Box::new(move |tx| { @@ -934,20 +937,51 @@ impl App { }); } + fn upsert_agent_picker_thread( + &mut self, + thread_id: ThreadId, + agent_nickname: Option, + agent_role: Option, + is_closed: bool, + ) { + self.agent_picker_threads.insert( + thread_id, + AgentPickerThreadEntry { + agent_nickname, + agent_role, + is_closed, + }, + ); + } + + fn mark_agent_picker_thread_closed(&mut self, thread_id: ThreadId) { + if let Some(entry) = self.agent_picker_threads.get_mut(&thread_id) { + entry.is_closed = true; + } else { + self.upsert_agent_picker_thread(thread_id, None, None, true); + } + } + async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { if self.active_thread_id == Some(thread_id) { return Ok(()); } - let thread = match self.server.get_thread(thread_id).await { - Ok(thread) => thread, + let live_thread = match self.server.get_thread(thread_id).await { + Ok(thread) => Some(thread), Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to attach to agent thread {thread_id}: {err}" - )); - return Ok(()); + if self.thread_event_channels.contains_key(&thread_id) { + self.mark_agent_picker_thread_closed(thread_id); + None + } else { + self.chat_widget.add_error_message(format!( + "Failed to attach to agent thread {thread_id}: {err}" + )); + return Ok(()); + } } }; + let is_replay_only = live_thread.is_none(); let previous_thread_id = self.active_thread_id; self.store_active_thread_receiver().await; @@ -965,11 +999,22 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread); + let codex_op_tx = if let Some(thread) = live_thread { + crate::chatwidget::spawn_op_forwarder(thread) + } else { + let (tx, _rx) = unbounded_channel(); + tx + }; self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot); + if is_replay_only { + self.chat_widget.add_info_message( + format!("Agent thread {thread_id} is closed. Replaying saved transcript."), + None, + ); + } self.drain_active_thread_events(tui).await?; Ok(()) @@ -989,12 +1034,55 @@ impl App { fn reset_thread_event_state(&mut self) { self.thread_event_channels.clear(); + self.agent_picker_threads.clear(); self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; self.pending_primary_events.clear(); } + async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) { + // Start a fresh in-memory session while preserving resumability via persisted rollout + // history. + let model = self.chat_widget.current_model().to_string(); + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.thread_id(), + self.chat_widget.thread_name(), + ); + self.shutdown_current_thread().await; + if let Err(err) = self.server.remove_and_close_all_threads().await { + tracing::warn!(error = %err, "failed to close all threads"); + } + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + // New sessions start without prefilled message content. + initial_user_message: None, + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + feedback_audience: self.feedback_audience, + model: Some(model), + status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), + otel_manager: self.otel_manager.clone(), + }; + self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.reset_thread_event_state(); + 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(); + } + async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { let Some(mut rx) = self.active_thread_rx.take() else { return Ok(()); @@ -1294,6 +1382,7 @@ impl App { pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), + agent_picker_threads: HashMap::new(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, @@ -1458,6 +1547,8 @@ impl App { { return Ok(AppRunControl::Continue); } + // Allow widgets to process any pending timers before rendering. + self.chat_widget.pre_draw_tick(); tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), |frame| { @@ -1481,47 +1572,18 @@ impl App { async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { - let model = self.chat_widget.current_model().to_string(); - let summary = session_summary( - self.chat_widget.token_usage(), - self.chat_widget.thread_id(), - self.chat_widget.thread_name(), - ); - self.shutdown_current_thread().await; - if let Err(err) = self.server.remove_and_close_all_threads().await { - tracing::warn!(error = %err, "failed to close all threads"); - } - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - // New sessions start without prefilled message content. - initial_user_message: None, - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - feedback_audience: self.feedback_audience, - model: Some(model), - status_line_invalid_items_warned: self.status_line_invalid_items_warned.clone(), - otel_manager: self.otel_manager.clone(), - }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.reset_thread_event_state(); - 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(); + self.start_fresh_session_with_summary_hint(tui).await; } AppEvent::ClearUi => { - self.clear_terminal_ui(tui)?; - tui.frame_requester().schedule_frame(); + self.clear_terminal_ui(tui, false)?; + self.overlay = None; + self.transcript_cells.clear(); + self.deferred_history_lines.clear(); + self.has_emitted_history_lines = false; + self.backtrack = BacktrackState::default(); + self.backtrack_render_pending = false; + + self.start_fresh_session_with_summary_hint(tui).await; } AppEvent::OpenResumePicker => { match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? { @@ -2643,6 +2705,22 @@ impl App { )); } }, + #[cfg(not(target_os = "linux"))] + AppEvent::TranscriptionComplete { id, text } => { + self.chat_widget.replace_transcription(&id, &text); + } + #[cfg(not(target_os = "linux"))] + AppEvent::TranscriptionFailed { id, error: _ } => { + self.chat_widget.remove_transcription_placeholder(&id); + } + #[cfg(not(target_os = "linux"))] + AppEvent::UpdateRecordingMeter { id, text } => { + // Update in place to preserve the element id for subsequent frames. + let updated = self.chat_widget.update_transcription_in_place(&id, &text); + if updated { + tui.frame_requester().schedule_frame(); + } + } AppEvent::StatusLineSetup { items } => { let ids = items.iter().map(ToString::to_string).collect::>(); let edit = codex_core::config::edit::status_line_items_edit(&ids); @@ -2752,7 +2830,7 @@ impl App { if let Some((closed_thread_id, primary_thread_id)) = self.active_non_primary_shutdown_target(&event.msg) { - self.thread_event_channels.remove(&closed_thread_id); + self.mark_agent_picker_thread_closed(closed_thread_id); self.select_agent_thread(tui, primary_thread_id).await?; if self.active_thread_id == Some(primary_thread_id) { self.chat_widget.add_info_message( @@ -2794,6 +2872,12 @@ impl App { } }; let config_snapshot = thread.config_snapshot().await; + self.upsert_agent_picker_thread( + thread_id, + config_snapshot.session_source.get_nickname(), + config_snapshot.session_source.get_agent_role(), + false, + ); let event = Event { id: String::new(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -3040,7 +3124,7 @@ impl App { self.chat_widget.handle_key_event(key_event); } _ => { - // Ignore Release key events. + self.chat_widget.handle_key_event(key_event); } }; } @@ -3078,28 +3162,6 @@ impl App { } } -fn format_agent_picker_item_name( - _thread_id: ThreadId, - agent_nickname: Option<&str>, - agent_role: Option<&str>, - is_primary: bool, -) -> String { - if is_primary { - return "Main [default]".to_string(); - } - - let agent_nickname = agent_nickname - .map(str::trim) - .filter(|nickname| !nickname.is_empty()); - let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); - match (agent_nickname, agent_role) { - (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), - (Some(agent_nickname), None) => agent_nickname.to_string(), - (None, Some(agent_role)) => format!("[{agent_role}]"), - (None, None) => "Agent".to_string(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -3268,7 +3330,7 @@ mod tests { } #[tokio::test] - async fn open_agent_picker_prunes_missing_threads() -> Result<()> { + async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> { let mut app = make_test_app().await; let thread_id = ThreadId::new(); app.thread_event_channels @@ -3276,7 +3338,44 @@ mod tests { app.open_agent_picker().await; - assert_eq!(app.thread_event_channels.contains_key(&thread_id), false); + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_picker_threads.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + is_closed: true, + }) + ); + Ok(()) + } + + #[tokio::test] + async fn open_agent_picker_keeps_cached_closed_threads() -> Result<()> { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(1)); + app.agent_picker_threads.insert( + thread_id, + AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: false, + }, + ); + + app.open_agent_picker().await; + + assert_eq!(app.thread_event_channels.contains_key(&thread_id), true); + assert_eq!( + app.agent_picker_threads.get(&thread_id), + Some(&AgentPickerThreadEntry { + agent_nickname: Some("Robie".to_string()), + agent_role: Some("explorer".to_string()), + is_closed: true, + }) + ); Ok(()) } @@ -3287,27 +3386,27 @@ mod tests { let snapshot = [ format!( "{} | {}", - format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), true), + format_agent_picker_item_name(Some("Robie"), Some("explorer"), true), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, Some("Robie"), Some("explorer"), false), + format_agent_picker_item_name(Some("Robie"), Some("explorer"), false), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, Some("Robie"), None, false), + format_agent_picker_item_name(Some("Robie"), None, false), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, None, Some("explorer"), false), + format_agent_picker_item_name(None, Some("explorer"), false), thread_id ), format!( "{} | {}", - format_agent_picker_item_name(thread_id, None, None, false), + format_agent_picker_item_name(None, None, false), thread_id ), ] @@ -3548,6 +3647,7 @@ mod tests { pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), + agent_picker_threads: HashMap::new(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, @@ -3606,6 +3706,7 @@ mod tests { pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), thread_event_channels: HashMap::new(), + agent_picker_threads: HashMap::new(), active_thread_id: None, active_thread_rx: None, primary_thread_id: None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 865ddb29e6..27ebe8cfe3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -54,7 +54,8 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, - /// Clear the terminal UI (screen + scrollback) without changing session state. + /// Clear the terminal UI (screen + scrollback), start a fresh session, and keep the + /// previous chat resumable. ClearUi, /// Open the resume picker inside the running TUI session. @@ -320,6 +321,29 @@ pub(crate) enum AppEvent { /// Re-open the permissions presets popup. OpenPermissionsPopup, + /// Live update for the in-progress voice recording placeholder. Carries + /// the placeholder `id` and the text to display (e.g., an ASCII meter). + #[cfg(not(target_os = "linux"))] + UpdateRecordingMeter { + id: String, + text: String, + }, + + /// Voice transcription finished for the given placeholder id. + #[cfg(not(target_os = "linux"))] + TranscriptionComplete { + id: String, + text: String, + }, + + /// Voice transcription failed; remove the placeholder identified by `id`. + #[cfg(not(target_os = "linux"))] + TranscriptionFailed { + id: String, + #[allow(dead_code)] + error: String, + }, + /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8498cd9b42..e81894471f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -109,11 +109,22 @@ //! edits and renders a placeholder prompt instead of the editable textarea. This is part of the //! overall state machine, since it affects which transitions are even possible from a given UI //! state. +//! +//! # Voice Hold-To-Talk Without Key Release +//! +//! On terminals that do not report `KeyEventKind::Release`, space hold-to-talk uses repeated +//! space key events as "still held" evidence: +//! +//! - For pending holds (non-empty composer), if timeout elapses without any repeated space event, +//! we treat the key as a normal typed space. +//! - If repeated space events are seen before timeout, we proceed with hold-to-talk. +//! - While recording, repeated space events keep the recording alive; if they stop for a short +//! window, we stop and transcribe. use crate::bottom_pane::footer::mode_indicator_line; -use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -191,6 +202,7 @@ 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::tui::FrameRequester; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_chatgpt::connectors; use codex_chatgpt::connectors::AppInfo; @@ -202,9 +214,17 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::ops::Range; use std::path::PathBuf; +use std::sync::Arc; +#[cfg(not(target_os = "linux"))] +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +#[cfg(not(target_os = "linux"))] +use std::thread; use std::time::Duration; use std::time::Instant; - +#[cfg(not(target_os = "linux"))] +use tokio::runtime::Handle; /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -284,6 +304,35 @@ impl ChatComposerConfig { } } } + +#[derive(Default)] +struct VoiceState { + transcription_enabled: bool, + // Spacebar hold-to-talk state. + space_hold_started_at: Option, + space_hold_element_id: Option, + space_hold_trigger: Option>, + key_release_supported: bool, + space_hold_repeat_seen: bool, + #[cfg(not(target_os = "linux"))] + voice: Option, + #[cfg(not(target_os = "linux"))] + recording_placeholder_id: Option, + #[cfg(not(target_os = "linux"))] + space_recording_started_at: Option, + #[cfg(not(target_os = "linux"))] + space_recording_last_repeat_at: Option, +} + +impl VoiceState { + fn new(key_release_supported: bool) -> Self { + Self { + key_release_supported, + ..Default::default() + } + } +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -299,10 +348,14 @@ pub(crate) struct ChatComposer { pending_pastes: Vec<(String, String)>, large_paste_counters: HashMap, has_focus: bool, + frame_requester: Option, /// Invariant: attached images are labeled in vec order as /// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images. attached_images: Vec, placeholder_text: String, + voice_state: VoiceState, + // Spinner control flags keyed by placeholder id; set to true to stop. + spinner_stop_flags: HashMap>, is_task_running: bool, /// When false, the composer is temporarily read-only (e.g. during sandbox setup). input_enabled: bool, @@ -320,6 +373,9 @@ pub(crate) struct ChatComposer { selected_remote_image_index: Option, footer_flash: Option, context_window_percent: Option, + // Monotonically increasing identifier for textarea elements we insert. + #[cfg(not(target_os = "linux"))] + next_element_id: u64, context_window_used_tokens: Option, skills: Option>, connectors_snapshot: Option, @@ -407,8 +463,11 @@ impl ChatComposer { pending_pastes: Vec::new(), large_paste_counters: HashMap::new(), has_focus: has_input_focus, + frame_requester: None, attached_images: Vec::new(), placeholder_text, + voice_state: VoiceState::new(enhanced_keys_supported), + spinner_stop_flags: HashMap::new(), is_task_running: false, input_enabled: true, input_disabled_placeholder: None, @@ -421,6 +480,8 @@ impl ChatComposer { selected_remote_image_index: None, footer_flash: None, context_window_percent: None, + #[cfg(not(target_os = "linux"))] + next_element_id: 0, context_window_used_tokens: None, skills: None, connectors_snapshot: None, @@ -442,6 +503,17 @@ impl ChatComposer { this } + #[cfg(not(target_os = "linux"))] + fn next_id(&mut self) -> String { + let id = self.next_element_id; + self.next_element_id = self.next_element_id.wrapping_add(1); + id.to_string() + } + + pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) { + self.frame_requester = Some(frame_requester); + } + pub fn set_skill_mentions(&mut self, skills: Option>) { self.skills = skills; } @@ -505,6 +577,23 @@ impl ChatComposer { pub fn set_personality_command_enabled(&mut self, enabled: bool) { self.personality_command_enabled = enabled; } + + pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { + self.voice_state.transcription_enabled = enabled; + if !enabled { + self.voice_state.space_hold_started_at = None; + if let Some(id) = self.voice_state.space_hold_element_id.take() { + let _ = self.textarea.replace_element_by_id(&id, " "); + } + self.voice_state.space_hold_trigger = None; + self.voice_state.space_hold_repeat_seen = false; + } + } + + #[cfg(not(target_os = "linux"))] + fn voice_transcription_enabled(&self) -> bool { + self.voice_state.transcription_enabled && cfg!(not(target_os = "linux")) + } /// Centralized feature gating keeps config checks out of call sites. fn popups_enabled(&self) -> bool { self.config.popups_enabled @@ -568,6 +657,20 @@ impl ChatComposer { } } + pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if !self.input_enabled { + return None; + } + + // Hide the cursor while recording voice input. + #[cfg(not(target_os = "linux"))] + if self.voice_state.voice.is_some() { + return None; + } + let [_, _, textarea_rect, _] = self.layout_areas(area); + let state = *self.textarea_state.borrow(); + self.textarea.cursor_pos_with_state(textarea_rect, state) + } /// Returns true if the composer currently contains no user-entered input. pub(crate) fn is_empty(&self) -> bool { self.textarea.is_empty() @@ -621,6 +724,10 @@ impl ChatComposer { /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect /// the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { + #[cfg(not(target_os = "linux"))] + if self.voice_state.voice.is_some() { + return false; + } let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { @@ -633,9 +740,8 @@ impl ChatComposer { { self.textarea.insert_str(" "); } else { - self.textarea.insert_str(&pasted); + self.insert_str(&pasted); } - // Explicit paste events should not trigger Enter suppression. self.paste_burst.clear_after_explicit_paste(); self.sync_popups(); true @@ -866,6 +972,9 @@ impl ChatComposer { local_image_paths: Vec, mention_bindings: Vec, ) { + #[cfg(not(target_os = "linux"))] + self.stop_all_transcription_spinners(); + // Clear any existing content, placeholders, and attachments first. self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); @@ -1124,20 +1233,56 @@ impl ChatComposer { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if matches!(key_event.kind, KeyEventKind::Release) { + self.voice_state.key_release_supported = true; + } + + // Timer-based conversion is handled in the pre-draw tick. + // If recording, stop on Space release when supported. On terminals without key-release + // events, Space repeat events are handled as "still held" and stop is driven by timeout + // in `process_space_hold_trigger`. + if let Some(result) = self.handle_key_event_while_recording(key_event) { + return result; + } + if !self.input_enabled { return (InputResult::None, false); } + // Outside of recording, ignore all key releases globally except for Space, + // which is handled explicitly for hold-to-talk behavior below. + if matches!(key_event.kind, KeyEventKind::Release) + && !matches!(key_event.code, KeyCode::Char(' ')) + { + return (InputResult::None, false); + } + + // If a space hold is pending and another non-space key is pressed, cancel the hold + // and convert the element into a plain space. + if self.voice_state.space_hold_started_at.is_some() + && !matches!(key_event.code, KeyCode::Char(' ')) + { + self.voice_state.space_hold_started_at = None; + if let Some(id) = self.voice_state.space_hold_element_id.take() { + let _ = self.textarea.replace_element_by_id(&id, " "); + } + self.voice_state.space_hold_trigger = None; + self.voice_state.space_hold_repeat_seen = false; + // fall through to normal handling of this other key + } + + if let Some(result) = self.handle_voice_space_key_event(&key_event) { + return result; + } + 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 } @@ -2535,6 +2680,7 @@ impl ChatComposer { // ------------------------------------------------------------- KeyEvent { code: KeyCode::Up | KeyCode::Down, + kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } | KeyEvent { @@ -2588,6 +2734,136 @@ impl ChatComposer { } } + #[cfg(target_os = "linux")] + fn handle_voice_space_key_event( + &mut self, + _key_event: &KeyEvent, + ) -> Option<(InputResult, bool)> { + None + } + + #[cfg(not(target_os = "linux"))] + fn handle_voice_space_key_event( + &mut self, + key_event: &KeyEvent, + ) -> Option<(InputResult, bool)> { + if !self.voice_transcription_enabled() || !matches!(key_event.code, KeyCode::Char(' ')) { + return None; + } + match key_event.kind { + KeyEventKind::Press => { + if self.paste_burst.is_active() { + return None; + } + + // If textarea is empty, start recording immediately without inserting a space. + if self.textarea.text().is_empty() { + if self.start_recording_with_placeholder() { + return Some((InputResult::None, true)); + } + return None; + } + + // If a hold is already pending, swallow further press events to + // avoid inserting multiple spaces and resetting the timer on key repeat. + if self.voice_state.space_hold_started_at.is_some() { + if !self.voice_state.key_release_supported { + self.voice_state.space_hold_repeat_seen = true; + } + return Some((InputResult::None, false)); + } + + // Insert a named element that renders as a space so we can later + // remove it on timeout or convert it to a plain space on release. + let elem_id = self.next_id(); + self.textarea.insert_named_element(" ", elem_id.clone()); + + // Record pending hold metadata. + self.voice_state.space_hold_started_at = Some(Instant::now()); + self.voice_state.space_hold_element_id = Some(elem_id); + self.voice_state.space_hold_repeat_seen = false; + + // Spawn a delayed task to flip an atomic flag; we check it on next key event. + let flag = Arc::new(AtomicBool::new(false)); + let frame = self.frame_requester.clone(); + Self::schedule_space_hold_timer(flag.clone(), frame); + self.voice_state.space_hold_trigger = Some(flag); + + Some((InputResult::None, true)) + } + // If we see a repeat before release, handling occurs in the top-level pending block. + KeyEventKind::Repeat => { + // Swallow repeats while a hold is pending to avoid extra spaces. + if self.voice_state.space_hold_started_at.is_some() { + if !self.voice_state.key_release_supported { + self.voice_state.space_hold_repeat_seen = true; + } + return Some((InputResult::None, false)); + } + // Fallback: if no pending hold, treat as normal input. + None + } + // Space release without pending (fallback): treat as normal input. + KeyEventKind::Release => { + // If a hold is pending, convert the element to a plain space and clear state. + self.voice_state.space_hold_started_at = None; + if let Some(id) = self.voice_state.space_hold_element_id.take() { + let _ = self.textarea.replace_element_by_id(&id, " "); + } + self.voice_state.space_hold_trigger = None; + self.voice_state.space_hold_repeat_seen = false; + Some((InputResult::None, true)) + } + } + } + + #[cfg(target_os = "linux")] + fn handle_key_event_while_recording( + &mut self, + _key_event: KeyEvent, + ) -> Option<(InputResult, bool)> { + None + } + + #[cfg(not(target_os = "linux"))] + fn handle_key_event_while_recording( + &mut self, + key_event: KeyEvent, + ) -> Option<(InputResult, bool)> { + if self.voice_state.voice.is_some() { + let should_stop = if self.voice_state.key_release_supported { + match key_event.kind { + KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')), + KeyEventKind::Press | KeyEventKind::Repeat => { + !matches!(key_event.code, KeyCode::Char(' ')) + } + } + } else { + match key_event.kind { + KeyEventKind::Release => matches!(key_event.code, KeyCode::Char(' ')), + KeyEventKind::Press | KeyEventKind::Repeat => { + if matches!(key_event.code, KeyCode::Char(' ')) { + self.voice_state.space_recording_last_repeat_at = Some(Instant::now()); + false + } else { + true + } + } + } + }; + + if should_stop { + let needs_redraw = self.stop_recording_and_start_transcription(); + return Some((InputResult::None, needs_redraw)); + } + + // Swallow non-stopping keys while recording. + return Some((InputResult::None, false)); + } + + None + } + fn is_bang_shell_command(&self) -> bool { self.textarea.text().trim_start().starts_with('!') } @@ -2607,8 +2883,6 @@ impl ChatComposer { 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 @@ -2632,6 +2906,12 @@ impl ChatComposer { /// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a /// timestamp to time out against. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + // Ignore key releases here to avoid treating them as additional input + // (e.g., appending the same character twice via paste-burst logic). + if !matches!(input.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return (InputResult::None, false); + } + self.handle_input_basic_with_time(input, Instant::now()) } @@ -2897,7 +3177,7 @@ impl ChatComposer { .map(|items| if items.is_empty() { 0 } else { 1 }) } - fn sync_popups(&mut self) { + pub(crate) fn sync_popups(&mut self) { self.sync_slash_command_elements(); if !self.popups_enabled() { self.active_popup = ActivePopup::None; @@ -3311,6 +3591,11 @@ impl ChatComposer { self.has_focus = has_focus; } + #[cfg(not(target_os = "linux"))] + pub(crate) fn is_recording(&self) -> bool { + self.voice_state.voice.is_some() + } + #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; @@ -3344,6 +3629,32 @@ impl ChatComposer { } } + #[cfg(not(target_os = "linux"))] + fn schedule_space_hold_timer(flag: Arc, frame: Option) { + const HOLD_DELAY_MILLIS: u64 = 500; + if let Ok(handle) = Handle::try_current() { + let flag_clone = flag; + let frame_clone = frame; + handle.spawn(async move { + tokio::time::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)).await; + Self::complete_space_hold_timer(flag_clone, frame_clone); + }); + } else { + thread::spawn(move || { + thread::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)); + Self::complete_space_hold_timer(flag, frame); + }); + } + } + + #[cfg(not(target_os = "linux"))] + fn complete_space_hold_timer(flag: Arc, frame: Option) { + flag.store(true, Ordering::Relaxed); + if let Some(frame) = frame { + frame.schedule_frame(); + } + } + pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { if self.status_line_value == status_line { return false; @@ -3361,6 +3672,280 @@ impl ChatComposer { } } +#[cfg(not(target_os = "linux"))] +impl ChatComposer { + pub(crate) fn process_space_hold_trigger(&mut self) { + if self.voice_transcription_enabled() + && let Some(flag) = self.voice_state.space_hold_trigger.as_ref() + && flag.load(Ordering::Relaxed) + && self.voice_state.space_hold_started_at.is_some() + && self.voice_state.voice.is_none() + { + let _ = self.on_space_hold_timeout(); + } + + const SPACE_REPEAT_INITIAL_GRACE_MILLIS: u64 = 700; + const SPACE_REPEAT_IDLE_TIMEOUT_MILLIS: u64 = 250; + if !self.voice_state.key_release_supported && self.voice_state.voice.is_some() { + let now = Instant::now(); + let initial_grace = Duration::from_millis(SPACE_REPEAT_INITIAL_GRACE_MILLIS); + let repeat_idle_timeout = Duration::from_millis(SPACE_REPEAT_IDLE_TIMEOUT_MILLIS); + if let Some(started_at) = self.voice_state.space_recording_started_at + && now.saturating_duration_since(started_at) >= initial_grace + { + let should_stop = match self.voice_state.space_recording_last_repeat_at { + Some(last_repeat_at) => { + now.saturating_duration_since(last_repeat_at) >= repeat_idle_timeout + } + None => true, + }; + if should_stop { + let _ = self.stop_recording_and_start_transcription(); + } + } + } + } + + /// Called when the 500ms space hold timeout elapses. + /// + /// On terminals without key-release reporting, this only transitions into voice capture if we + /// observed repeated Space events while pending; otherwise the keypress is treated as a typed + /// space. + pub(crate) fn on_space_hold_timeout(&mut self) -> bool { + if !self.voice_transcription_enabled() { + return false; + } + if self.voice_state.voice.is_some() { + return false; + } + if self.voice_state.space_hold_started_at.is_some() { + if !self.voice_state.key_release_supported && !self.voice_state.space_hold_repeat_seen { + if let Some(id) = self.voice_state.space_hold_element_id.take() { + let _ = self.textarea.replace_element_by_id(&id, " "); + } + self.voice_state.space_hold_started_at = None; + self.voice_state.space_hold_trigger = None; + self.voice_state.space_hold_repeat_seen = false; + return true; + } + + // Preserve the typed space when transitioning into voice capture, but + // avoid duplicating an existing trailing space. In either case, + // convert/remove the temporary named element before inserting the + // recording/transcribing placeholder. + if let Some(id) = self.voice_state.space_hold_element_id.take() { + let replacement = if self + .textarea + .named_element_range(&id) + .and_then(|range| self.textarea.text()[..range.start].chars().next_back()) + .is_some_and(|ch| ch == ' ') + { + "" + } else { + " " + }; + let _ = self.textarea.replace_element_by_id(&id, replacement); + } + // Clear pending state before starting capture + self.voice_state.space_hold_started_at = None; + self.voice_state.space_hold_trigger = None; + self.voice_state.space_hold_repeat_seen = false; + + // Start voice capture + self.start_recording_with_placeholder() + } else { + false + } + } + + /// Stop recording if active, update the placeholder, and spawn background transcription. + /// Returns true if the UI should redraw. + fn stop_recording_and_start_transcription(&mut self) -> bool { + let Some(vc) = self.voice_state.voice.take() else { + return false; + }; + self.voice_state.space_recording_started_at = None; + self.voice_state.space_recording_last_repeat_at = None; + match vc.stop() { + Ok(audio) => { + // If the recording is too short, remove the placeholder immediately + // and skip the transcribing state entirely. + let total_samples = audio.data.len() as f32; + let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32); + let duration_seconds = if samples_per_second > 0.0 { + total_samples / samples_per_second + } else { + 0.0 + }; + const MIN_DURATION_SECONDS: f32 = 1.0; + if duration_seconds < MIN_DURATION_SECONDS { + if let Some(id) = self.voice_state.recording_placeholder_id.take() { + let _ = self.textarea.replace_element_by_id(&id, ""); + } + return true; + } + + // Otherwise, update the placeholder to show a spinner and proceed. + let id = match self.voice_state.recording_placeholder_id.take() { + Some(id) => id, + None => self.next_id(), + }; + + let placeholder_range = self.textarea.named_element_range(&id); + let prompt_source = if let Some(range) = &placeholder_range { + self.textarea.text()[..range.start].to_string() + } else { + self.textarea.text().to_string() + }; + + // Initialize with first spinner frame immediately. + let _ = self.textarea.update_named_element_by_id(&id, "⠋"); + // Spawn animated braille spinner until transcription finishes (or times out). + self.spawn_transcribing_spinner(id.clone()); + let tx = self.app_event_tx.clone(); + crate::voice::transcribe_async(id, audio, Some(prompt_source), tx); + true + } + Err(e) => { + tracing::error!("failed to stop voice capture: {e}"); + true + } + } + } + + /// Start voice capture and insert a placeholder element for the live meter. + /// Returns true if recording began and UI should redraw; false on failure. + fn start_recording_with_placeholder(&mut self) -> bool { + match crate::voice::VoiceCapture::start() { + Ok(vc) => { + self.voice_state.voice = Some(vc); + if self.voice_state.key_release_supported { + self.voice_state.space_recording_started_at = None; + } else { + self.voice_state.space_recording_started_at = Some(Instant::now()); + } + self.voice_state.space_recording_last_repeat_at = None; + // Insert visible placeholder for the meter (no label) + let id = self.next_id(); + self.textarea.insert_named_element("", id.clone()); + self.voice_state.recording_placeholder_id = Some(id); + // Spawn metering animation + if let Some(v) = &self.voice_state.voice { + let data = v.data_arc(); + let stop = v.stopped_flag(); + let sr = v.sample_rate(); + let ch = v.channels(); + let peak = v.last_peak_arc(); + if let Some(idref) = &self.voice_state.recording_placeholder_id { + self.spawn_recording_meter(idref.clone(), sr, ch, data, peak, stop); + } + } + true + } + Err(e) => { + self.voice_state.space_recording_started_at = None; + self.voice_state.space_recording_last_repeat_at = None; + tracing::error!("failed to start voice capture: {e}"); + false + } + } + } + + fn spawn_recording_meter( + &self, + id: String, + _sample_rate: u32, + _channels: u16, + _data: Arc>>, + last_peak: Arc, + stop: Arc, + ) { + let tx = self.app_event_tx.clone(); + let task = move || { + use std::time::Duration; + let mut meter = crate::voice::RecordingMeterState::new(); + loop { + if stop.load(Ordering::Relaxed) { + break; + } + let text = meter.next_text(last_peak.load(Ordering::Relaxed)); + tx.send(crate::app_event::AppEvent::UpdateRecordingMeter { + id: id.clone(), + text, + }); + + thread::sleep(Duration::from_millis(100)); + } + }; + + if let Ok(handle) = Handle::try_current() { + handle.spawn_blocking(task); + } else { + thread::spawn(task); + } + } + + fn spawn_transcribing_spinner(&mut self, id: String) { + self.stop_transcription_spinner(&id); + let stop = Arc::new(AtomicBool::new(false)); + self.spinner_stop_flags + .insert(id.clone(), Arc::clone(&stop)); + + let tx = self.app_event_tx.clone(); + let task = move || { + use std::time::Duration; + let frames: Vec<&'static str> = vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let mut i: usize = 0; + // Safety stop after ~60s to avoid a runaway task if events are lost. + let max_ticks = 600usize; // 600 * 100ms = 60s + for _ in 0..max_ticks { + if stop.load(Ordering::Relaxed) { + break; + } + let text = frames[i % frames.len()].to_string(); + tx.send(crate::app_event::AppEvent::UpdateRecordingMeter { + id: id.clone(), + text, + }); + i = i.wrapping_add(1); + thread::sleep(Duration::from_millis(100)); + } + }; + + if let Ok(handle) = Handle::try_current() { + handle.spawn_blocking(task); + } else { + thread::spawn(task); + } + } + + fn stop_transcription_spinner(&mut self, id: &str) { + if let Some(flag) = self.spinner_stop_flags.remove(id) { + flag.store(true, Ordering::Relaxed); + } + } + + fn stop_all_transcription_spinners(&mut self) { + for (_id, flag) in self.spinner_stop_flags.drain() { + flag.store(true, Ordering::Relaxed); + } + } + + pub fn replace_transcription(&mut self, id: &str, text: &str) { + self.stop_transcription_spinner(id); + let _ = self.textarea.replace_element_by_id(id, text); + } + + pub fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { + self.textarea.update_named_element_by_id(id, text) + } + + pub fn remove_transcription_placeholder(&mut self, id: &str) { + self.stop_transcription_spinner(id); + let _ = self.textarea.replace_element_by_id(id, ""); + } +} + fn skill_display_name(skill: &SkillMetadata) -> &str { skill .interface @@ -3787,6 +4372,15 @@ fn prompt_selection_action( } } +impl Drop for ChatComposer { + fn drop(&mut self) { + // Stop any running spinner tasks. + for (_id, flag) in self.spinner_stop_flags.drain() { + flag.store(true, Ordering::Relaxed); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -5527,11 +6121,19 @@ mod tests { fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; + use crossterm::event::KeyEventKind; 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(); + if ch == ' ' { + let _ = composer.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Char(' '), + KeyModifiers::NONE, + KeyEventKind::Release, + )); + } } } @@ -5621,6 +6223,195 @@ mod tests { assert!(found_error, "expected error history cell to be sent"); } + #[test] + fn voice_transcription_disabled_treats_space_as_normal_input() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyEventKind; + 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(), + true, + ); + composer.set_text_content("x".to_string(), Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + let _ = composer.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Char(' '), + KeyModifiers::NONE, + KeyEventKind::Release, + )); + + assert_eq!("x ", composer.textarea.text()); + assert!(composer.voice_state.space_hold_started_at.is_none()); + assert!(composer.voice_state.space_hold_element_id.is_none()); + assert!(composer.voice_state.space_hold_trigger.is_none()); + assert!(!composer.voice_state.space_hold_repeat_seen); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn space_hold_timeout_without_release_or_repeat_keeps_typed_space() { + 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_voice_transcription_enabled(true); + + composer.set_text_content("x".to_string(), Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + let elem_id = "space-hold".to_string(); + composer.textarea.insert_named_element(" ", elem_id.clone()); + composer.voice_state.space_hold_started_at = Some(Instant::now()); + composer.voice_state.space_hold_element_id = Some(elem_id); + composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true))); + composer.voice_state.key_release_supported = false; + composer.voice_state.space_hold_repeat_seen = false; + assert_eq!("x ", composer.textarea.text()); + + composer.process_space_hold_trigger(); + + assert_eq!("x ", composer.textarea.text()); + assert!(composer.voice_state.space_hold_started_at.is_none()); + assert!(!composer.voice_state.space_hold_repeat_seen); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn space_hold_timeout_with_repeat_uses_hold_path_without_release() { + 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_voice_transcription_enabled(true); + + composer.set_text_content("x".to_string(), Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + let elem_id = "space-hold".to_string(); + composer.textarea.insert_named_element(" ", elem_id.clone()); + composer.voice_state.space_hold_started_at = Some(Instant::now()); + composer.voice_state.space_hold_element_id = Some(elem_id); + composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true))); + composer.voice_state.key_release_supported = false; + composer.voice_state.space_hold_repeat_seen = true; + + composer.process_space_hold_trigger(); + + assert_eq!("x ", composer.textarea.text()); + assert!(composer.voice_state.space_hold_started_at.is_none()); + assert!(!composer.voice_state.space_hold_repeat_seen); + if composer.is_recording() { + let _ = composer.stop_recording_and_start_transcription(); + } + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn space_hold_timeout_with_repeat_does_not_duplicate_existing_space() { + 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_voice_transcription_enabled(true); + + composer.set_text_content("x ".to_string(), Vec::new(), Vec::new()); + composer.move_cursor_to_end(); + let elem_id = "space-hold".to_string(); + composer.textarea.insert_named_element(" ", elem_id.clone()); + composer.voice_state.space_hold_started_at = Some(Instant::now()); + composer.voice_state.space_hold_element_id = Some(elem_id); + composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true))); + composer.voice_state.key_release_supported = false; + composer.voice_state.space_hold_repeat_seen = true; + + composer.process_space_hold_trigger(); + + assert_eq!("x ", composer.textarea.text()); + assert!(composer.voice_state.space_hold_started_at.is_none()); + assert!(!composer.voice_state.space_hold_repeat_seen); + if composer.is_recording() { + let _ = composer.stop_recording_and_start_transcription(); + } + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn replace_transcription_stops_spinner_for_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 id = "voice-placeholder".to_string(); + composer.textarea.insert_named_element("", id.clone()); + let flag = Arc::new(AtomicBool::new(false)); + composer + .spinner_stop_flags + .insert(id.clone(), Arc::clone(&flag)); + + composer.replace_transcription(&id, "transcribed text"); + + assert!(flag.load(Ordering::Relaxed)); + assert!(!composer.spinner_stop_flags.contains_key(&id)); + assert_eq!(composer.textarea.text(), "transcribed text"); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn set_text_content_stops_all_transcription_spinners() { + 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 flag_one = Arc::new(AtomicBool::new(false)); + let flag_two = Arc::new(AtomicBool::new(false)); + composer + .spinner_stop_flags + .insert("voice-1".to_string(), Arc::clone(&flag_one)); + composer + .spinner_stop_flags + .insert("voice-2".to_string(), Arc::clone(&flag_two)); + + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + assert!(flag_one.load(Ordering::Relaxed)); + assert!(flag_two.load(Ordering::Relaxed)); + assert!(composer.spinner_stop_flags.is_empty()); + } + #[test] fn extract_args_supports_quoted_paths_single_arg() { let args = extract_positional_args_for_prompt_line( diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1c2291465e..48b6ffedc4 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -220,6 +220,7 @@ impl CommandPopup { }; GenericDisplayRow { name, + name_prefix_spans: Vec::new(), match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()), display_shortcut: None, description: Some(description), 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 9eb8121b69..76f8bc1e1a 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -119,6 +119,7 @@ impl WidgetRef for &FileSearchPopup { .iter() .map(|m| GenericDisplayRow { name: m.path.to_string_lossy().to_string(), + name_prefix_spans: Vec::new(), match_indices: m .indices .as_ref() diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index f6f61acf4b..28005a172a 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -990,7 +990,7 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ #[cfg(test)] mod tests { use super::*; - use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; + use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::test_backend::VT100Backend; use insta::assert_snapshot; use pretty_assertions::assert_eq; 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 ad40c1660f..2f566079aa 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -112,6 +112,7 @@ pub(crate) type OnCancelCallback = Option>, pub display_shortcut: Option, pub description: Option, pub selected_description: Option, @@ -372,7 +373,9 @@ impl ListSelectionView { format!("{prefix} {n}. ") }; let wrap_prefix_width = UnicodeWidthStr::width(wrap_prefix.as_str()); - let display_name = format!("{wrap_prefix}{name_with_marker}"); + let mut name_prefix_spans = Vec::new(); + name_prefix_spans.push(wrap_prefix.into()); + name_prefix_spans.extend(item.name_prefix_spans.clone()); let description = is_selected .then(|| item.selected_description.clone()) .flatten() @@ -380,7 +383,8 @@ impl ListSelectionView { let wrap_indent = description.is_none().then_some(wrap_prefix_width); let is_disabled = item.is_disabled || item.disabled_reason.is_some(); GenericDisplayRow { - name: display_name, + name: name_with_marker, + name_prefix_spans, display_shortcut: item.display_shortcut, match_indices: None, description, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9c7b940f0e..20eb840a2e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -33,6 +33,7 @@ use codex_protocol::request_user_input::RequestUserInputEvent; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Line; @@ -131,6 +132,7 @@ pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; +use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; @@ -203,8 +205,8 @@ impl BottomPane { placeholder_text, disable_paste_burst, ); + composer.set_frame_requester(frame_requester.clone()); composer.set_skill_mentions(skills); - Self { composer, view_stack: Vec::new(), @@ -290,6 +292,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { + self.composer.set_voice_transcription_enabled(enabled); + self.request_redraw(); + } + /// Update the key hint shown next to queued messages so it matches the /// binding that `ChatWidget` actually listens for. pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) { @@ -326,8 +333,23 @@ impl BottomPane { /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { + // Do not globally intercept space; only composer handles hold-to-talk. + // While recording, route all keys to the composer so it can stop on release or next key. + #[cfg(not(target_os = "linux"))] + if self.composer.is_recording() { + let (_ir, needs_redraw) = self.composer.handle_key_event(key_event); + if needs_redraw { + self.request_redraw(); + } + return InputResult::None; + } + // If a modal/view is active, handle it here; otherwise forward to composer. if !self.view_stack.is_empty() { + if key_event.kind == KeyEventKind::Release { + return InputResult::None; + } + // We need three pieces of information after routing the key: // whether Esc completed the view, whether the view finished for any // reason, and whether a paste-burst timer should be scheduled. @@ -431,6 +453,7 @@ impl BottomPane { } } else { let needs_redraw = self.composer.handle_paste(pasted); + self.composer.sync_popups(); if needs_redraw { self.request_redraw(); } @@ -439,9 +462,18 @@ impl BottomPane { pub(crate) fn insert_str(&mut self, text: &str) { self.composer.insert_str(text); + self.composer.sync_popups(); self.request_redraw(); } + // Space hold timeout is handled inside ChatComposer via an internal timer. + pub(crate) fn pre_draw_tick(&mut self) { + // Allow composer to process any time-based transitions before drawing + #[cfg(not(target_os = "linux"))] + self.composer.process_space_hold_trigger(); + self.composer.sync_popups(); + } + /// Replace the composer text with `text`. /// /// This is intended for fresh input where mention linkage does not need to @@ -549,10 +581,16 @@ impl BottomPane { /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. - pub(crate) fn update_status(&mut self, header: String, details: Option) { + pub(crate) fn update_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) { if let Some(status) = self.status.as_mut() { status.update_header(header); - status.update_details(details); + status.update_details(details, details_capitalization, details_max_lines.max(1)); self.request_redraw(); } } @@ -888,6 +926,7 @@ impl BottomPane { .on_history_entry_response(log_id, offset, entry); if updated { + self.composer.sync_popups(); self.request_redraw(); } } @@ -966,6 +1005,30 @@ impl BottomPane { } } +#[cfg(not(target_os = "linux"))] +impl BottomPane { + pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) { + self.composer.replace_transcription(id, text); + self.composer.sync_popups(); + self.request_redraw(); + } + + pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { + let updated = self.composer.update_transcription_in_place(id, text); + if updated { + self.composer.sync_popups(); + self.request_redraw(); + } + updated + } + + pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { + self.composer.remove_transcription_placeholder(id); + self.composer.sync_popups(); + self.request_redraw(); + } +} + impl Renderable for BottomPane { fn render(&self, area: Rect, buf: &mut Buffer) { self.as_renderable().render(area, buf); @@ -982,8 +1045,11 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; + use crate::status_indicator_widget::StatusDetailsCapitalization; use codex_protocol::protocol::Op; use codex_protocol::protocol::SkillScope; + use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use ratatui::buffer::Buffer; @@ -1275,6 +1341,8 @@ mod tests { pane.update_status( "Working".to_string(), Some("First detail line\nSecond detail line".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, ); pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); @@ -1560,4 +1628,58 @@ mod tests { assert_eq!(on_ctrl_c_calls.get(), 0); assert_eq!(handle_calls.get(), 1); } + + #[test] + fn release_events_are_ignored_for_active_view() { + #[derive(Default)] + struct CountingView { + handle_calls: Rc>, + } + + impl Renderable for CountingView { + fn render(&self, _area: Rect, _buf: &mut Buffer) {} + + fn desired_height(&self, _width: u16) -> u16 { + 0 + } + } + + impl BottomPaneView for CountingView { + fn handle_key_event(&mut self, _key_event: KeyEvent) { + self.handle_calls + .set(self.handle_calls.get().saturating_add(1)); + } + } + + 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()), + }); + + let handle_calls = Rc::new(Cell::new(0)); + pane.push_view(Box::new(CountingView { + handle_calls: Rc::clone(&handle_calls), + })); + + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::NONE, + KeyEventKind::Press, + )); + pane.handle_key_event(KeyEvent::new_with_kind( + KeyCode::Down, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + + assert_eq!(handle_calls.get(), 1); + } } diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index 65dfbb7741..a8acf18668 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -46,8 +46,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView; use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS; use crate::bottom_pane::scroll_state::ScrollState; use crate::bottom_pane::selection_popup_common::render_rows_single_line; -use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; use crate::render::RectExt; use crate::render::renderable::ColumnRenderable; 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 6b55125c9f..85f5a1c612 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -14,6 +14,7 @@ use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use crate::key_hint::KeyBinding; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::Insets; use crate::render::RectExt as _; use crate::style::user_message_style; @@ -28,6 +29,7 @@ use super::scroll_state::ScrollState; #[derive(Default)] pub(crate) struct GenericDisplayRow { pub name: String, + pub name_prefix_spans: Vec>, pub display_shortcut: Option, pub match_indices: Option>, // indices to bold (char positions) pub description: Option, // optional grey text after the name @@ -104,12 +106,6 @@ pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec) -> usize { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum() -} - fn line_to_owned(line: Line<'_>) -> Line<'static> { Line { style: line.style, @@ -125,91 +121,6 @@ fn line_to_owned(line: Line<'_>) -> Line<'static> { } } -pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let Line { - style, - alignment, - spans, - } = line; - let mut used = 0usize; - let mut spans_out: Vec> = Vec::new(); - - for span in spans { - let text = span.content.into_owned(); - let style = span.style; - let span_width = UnicodeWidthStr::width(text.as_str()); - - if span_width == 0 { - spans_out.push(Span::styled(text, style)); - continue; - } - - if used >= max_width { - break; - } - - if used + span_width <= max_width { - used += span_width; - spans_out.push(Span::styled(text, style)); - continue; - } - - let mut truncated = String::new(); - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if used + ch_width > max_width { - break; - } - truncated.push(ch); - used += ch_width; - } - - if !truncated.is_empty() { - spans_out.push(Span::styled(truncated, style)); - } - - break; - } - - Line { - style, - alignment, - spans: spans_out, - } -} - -pub(crate) fn truncate_line_with_ellipsis_if_overflow( - line: Line<'static>, - max_width: usize, -) -> Line<'static> { - if max_width == 0 { - return Line::from(Vec::>::new()); - } - - let width = line_width(&line); - if width <= max_width { - return line; - } - - let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); - let Line { - style, - alignment, - mut spans, - } = truncated; - let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default(); - spans.push(Span::styled("…", ellipsis_style)); - Line { - style, - alignment, - spans, - } -} - fn compute_desc_col( rows_all: &[GenericDisplayRow], start_idx: usize, @@ -242,7 +153,8 @@ fn compute_desc_col( .skip(start_idx) .take(visible_items) .map(|(_, row)| { - let mut spans: Vec = vec![row.name.clone().into()]; + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); if row.disabled_reason.is_some() { spans.push(" (disabled)".dim()); } @@ -253,7 +165,8 @@ fn compute_desc_col( ColumnWidthMode::AutoAllRows => rows_all .iter() .map(|row| { - let mut spans: Vec = vec![row.name.clone().into()]; + let mut spans = row.name_prefix_spans.clone(); + spans.push(row.name.clone().into()); if row.disabled_reason.is_some() { spans.push(" (disabled)".dim()); } @@ -291,6 +204,7 @@ fn should_wrap_name_in_column(row: &GenericDisplayRow) -> bool { && row.match_indices.is_none() && row.display_shortcut.is_none() && row.category_tag.is_none() + && row.name_prefix_spans.is_empty() } fn wrap_two_column_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec> { @@ -508,9 +422,10 @@ 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_prefix_width = Line::from(row.name_prefix_spans.clone()).width(); let name_limit = combined_description .as_ref() - .map(|_| desc_col.saturating_sub(2)) + .map(|_| desc_col.saturating_sub(2).saturating_sub(name_prefix_width)) .unwrap_or(usize::MAX); let mut name_spans: Vec = Vec::with_capacity(row.name.len()); @@ -558,8 +473,9 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> { name_spans.push(" (disabled)".dim()); } - let this_name_width = Line::from(name_spans.clone()).width(); - let mut full_spans: Vec = name_spans; + let this_name_width = name_prefix_width + Line::from(name_spans.clone()).width(); + let mut full_spans: Vec = row.name_prefix_spans.clone(); + full_spans.extend(name_spans); if let Some(display_shortcut) = row.display_shortcut { full_spans.push(" (".into()); full_spans.push(display_shortcut.into()); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index e3a6c9880b..c848bca9ea 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -101,6 +101,7 @@ impl SkillPopup { let description = mention.description.clone().unwrap_or_default(); GenericDisplayRow { name, + name_prefix_spans: Vec::new(), match_indices: indices, display_shortcut: None, description: Some(description).filter(|desc| !desc.is_empty()), diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap index 494883e4c3..5c95c9f811 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap @@ -2,7 +2,7 @@ source: tui/src/bottom_pane/mod.rs expression: "render_snapshot(&pane, area)" --- -• Working (0s • esc to interru +• Working (0s • esc to interr… › Ask Codex to do anything diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index a32b584c2a..fbb30a0322 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -3,6 +3,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -27,6 +28,7 @@ fn is_word_separator(ch: char) -> bool { struct TextElement { id: u64, range: Range, + name: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -101,6 +103,7 @@ impl TextArea { self.elements.push(TextElement { id, range: start..end, + name: None, }); } self.elements.sort_by_key(|e| e.range.start); @@ -256,6 +259,11 @@ impl TextArea { } pub fn input(&mut self, event: KeyEvent) { + // Only process key presses or repeats; ignore releases to avoid inserting + // characters on key-up events when modifiers are no longer reported. + if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return; + } match event { // Some terminals (or configurations) send Control key chords as // C0 control characters without reporting the CONTROL modifier. @@ -886,6 +894,73 @@ impl TextArea { id } + #[cfg(not(target_os = "linux"))] + pub fn insert_named_element(&mut self, text: &str, id: String) { + let start = self.clamp_pos_for_insertion(self.cursor_pos); + self.insert_str_at(start, text); + let end = start + text.len(); + self.add_element_with_id(start..end, Some(id)); + // Place cursor at end of inserted element + self.set_cursor(end); + } + + pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool { + if let Some(idx) = self + .elements + .iter() + .position(|e| e.name.as_deref() == Some(id)) + { + let range = self.elements[idx].range.clone(); + self.replace_range_raw(range, text); + self.elements.retain(|e| e.name.as_deref() != Some(id)); + true + } else { + false + } + } + + /// Update the element's text in place, preserving its id so callers can + /// update it again later (e.g. recording -> transcribing -> final). + #[allow(dead_code)] + pub fn update_named_element_by_id(&mut self, id: &str, text: &str) -> bool { + if let Some(elem_idx) = self + .elements + .iter() + .position(|e| e.name.as_deref() == Some(id)) + { + let old_range = self.elements[elem_idx].range.clone(); + let start = old_range.start; + self.replace_range_raw(old_range, text); + // After replace_range_raw, the old element entry was removed if fully overlapped. + // Re-add an updated element with the same id and new range. + let new_end = start + text.len(); + self.add_element_with_id(start..new_end, Some(id.to_string())); + true + } else { + false + } + } + + #[allow(dead_code)] + pub fn named_element_range(&self, id: &str) -> Option> { + self.elements + .iter() + .find(|e| e.name.as_deref() == Some(id)) + .map(|e| e.range.clone()) + } + + fn add_element_with_id(&mut self, range: Range, name: Option) -> u64 { + let id = self.next_element_id(); + let elem = TextElement { id, range, name }; + self.elements.push(elem); + self.elements.sort_by_key(|e| e.range.start); + id + } + + fn add_element(&mut self, range: Range) -> u64 { + self.add_element_with_id(range, None) + } + /// Mark an existing text range as an atomic element without changing the text. /// /// This is used to convert already-typed tokens (like `/plan`) into elements @@ -910,12 +985,7 @@ impl TextArea { { return None; } - let id = self.next_element_id(); - self.elements.push(TextElement { - id, - range: start..end, - }); - self.elements.sort_by_key(|e| e.range.start); + let id = self.add_element(start..end); Some(id) } @@ -931,20 +1001,11 @@ impl TextArea { len_before != self.elements.len() } - fn add_element(&mut self, range: Range) -> u64 { - let id = self.next_element_id(); - let elem = TextElement { id, range }; - self.elements.push(elem); - self.elements.sort_by_key(|e| e.range.start); - id - } - fn next_element_id(&mut self) -> u64 { let id = self.next_element_id; self.next_element_id = self.next_element_id.saturating_add(1); id } - fn find_element_containing(&self, pos: usize) -> Option { self.elements .iter() diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 309ce80ce8..b6e1f5b16b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -49,6 +49,7 @@ use codex_app_server_protocol::ConfigLayerSource; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_core::config::Config; +use codex_core::config::Constrained; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; use codex_core::config::types::WindowsSandboxModeToml; @@ -242,6 +243,8 @@ use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; +use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; +use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -910,15 +913,27 @@ impl ChatWidget { /// Update the status indicator header and details. /// /// Passing `None` clears any existing details. - fn set_status(&mut self, header: String, details: Option) { + fn set_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) { self.current_status_header = header.clone(); - self.bottom_pane.update_status(header, details); + self.bottom_pane + .update_status(header, details, details_capitalization, details_max_lines); } /// Convenience wrapper around [`Self::set_status`]; /// updates the status indicator header and clears any existing details. fn set_status_header(&mut self, header: String) { - self.set_status(header, None); + self.set_status( + header, + None, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); } /// Sets the currently rendered footer status-line value. @@ -1076,6 +1091,27 @@ impl ChatWidget { self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.clone()); + self.config.cwd = event.cwd.clone(); + if let Err(err) = self + .config + .permissions + .approval_policy + .set(event.approval_policy) + { + tracing::warn!(%err, "failed to sync approval_policy from SessionConfigured"); + self.config.permissions.approval_policy = + Constrained::allow_only(event.approval_policy); + } + if let Err(err) = self + .config + .permissions + .sandbox_policy + .set(event.sandbox_policy.clone()) + { + tracing::warn!(%err, "failed to sync sandbox_policy from SessionConfigured"); + self.config.permissions.sandbox_policy = + Constrained::allow_only(event.sandbox_policy.clone()); + } let initial_messages = event.initial_messages.clone(); let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); @@ -1933,15 +1969,16 @@ impl ChatWidget { .map(|process| process.command_display.clone()); if ev.stdin.is_empty() { // Empty stdin means we are polling for background output. - // Surface this in the status header (single "waiting" surface) instead of the transcript. + // Surface this in the status indicator (single "waiting" surface) instead of + // the transcript. Keep the header short so the interrupt hint remains visible. self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(true); - let header = if let Some(command) = &command_display { - format!("Waiting for background terminal · {command}") - } else { - "Waiting for background terminal".to_string() - }; - self.set_status_header(header); + self.set_status( + "Waiting for background terminal".to_string(), + command_display.clone(), + StatusDetailsCapitalization::Preserve, + 1, + ); match &mut self.unified_exec_wait_streak { Some(wait) if wait.process_id == ev.process_id => { wait.update_command_display(command_display); @@ -2214,7 +2251,16 @@ impl ChatWidget { self.retry_status_header = Some(self.current_status_header.clone()); } self.bottom_pane.ensure_status_indicator(); - self.set_status(message, additional_details); + self.set_status( + message, + additional_details, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + } + + pub(crate) fn pre_draw_tick(&mut self) { + self.bottom_pane.pre_draw_tick(); } /// Handle completion of an `AgentMessage` turn item. @@ -2806,12 +2852,13 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_voice_transcription_enabled( + widget.config.features.enabled(Feature::VoiceTranscription), + ); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); + widget.bottom_pane.set_collaboration_modes_enabled(true); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -2976,12 +3023,13 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_voice_transcription_enabled( + widget.config.features.enabled(Feature::VoiceTranscription), + ); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); + widget.bottom_pane.set_collaboration_modes_enabled(true); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3135,12 +3183,13 @@ impl ChatWidget { widget .bottom_pane .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_voice_transcription_enabled( + widget.config.features.enabled(Feature::VoiceTranscription), + ); widget .bottom_pane .set_status_line_enabled(!widget.configured_status_line_items().is_empty()); - widget.bottom_pane.set_collaboration_modes_enabled( - widget.config.features.enabled(Feature::CollaborationModes), - ); + widget.bottom_pane.set_collaboration_modes_enabled(true); widget.sync_personality_command_enabled(); widget .bottom_pane @@ -3258,7 +3307,14 @@ impl ChatWidget { .bottom_pane .take_recent_submission_mention_bindings(), }; - if self.is_session_configured() && !self.is_plan_streaming_in_tui() { + // Steer submissions during active final-answer streaming can race with turn + // completion and strand the UI in a running state. Queue those inputs instead + // of injecting immediately; `on_task_complete()` drains this FIFO via + // `maybe_send_next_queued_input()`, so no typed prompt is dropped. + let should_submit_now = self.is_session_configured() + && !self.is_plan_streaming_in_tui() + && self.stream_controller.is_none(); + if should_submit_now { // Submitted is only emitted when steer is enabled. // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); @@ -6270,6 +6326,8 @@ impl ChatWidget { self.set_status( "Setting up sandbox...".to_string(), Some("Hang tight, this may take a few minutes".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, ); self.request_redraw(); } @@ -6325,21 +6383,8 @@ impl ChatWidget { if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); } - if feature == Feature::CollaborationModes { - self.bottom_pane.set_collaboration_modes_enabled(enabled); - let settings = self.current_collaboration_mode.settings.clone(); - self.current_collaboration_mode = CollaborationMode { - mode: ModeKind::Default, - settings, - }; - self.active_collaboration_mask = if enabled { - collaboration_modes::default_mask(self.models_manager.as_ref()) - } else { - None - }; - self.update_collaboration_mode_indicator(); - self.refresh_model_display(); - self.request_redraw(); + if feature == Feature::VoiceTranscription { + self.bottom_pane.set_voice_transcription_enabled(enabled); } if feature == Feature::Personality { self.sync_personality_command_enabled(); @@ -6519,17 +6564,14 @@ impl ChatWidget { } fn collaboration_modes_enabled(&self) -> bool { - self.config.features.enabled(Feature::CollaborationModes) + true } fn initial_collaboration_mask( - config: &Config, + _config: &Config, models_manager: &ModelsManager, model_override: Option<&str>, ) -> Option { - if !config.features.enabled(Feature::CollaborationModes) { - return None; - } let mut mask = collaboration_modes::default_mask(models_manager)?; if let Some(model_override) = model_override { mask.model = Some(model_override.to_string()); @@ -7495,6 +7537,29 @@ impl ChatWidget { } } +#[cfg(not(target_os = "linux"))] +impl ChatWidget { + pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) { + self.bottom_pane.replace_transcription(id, text); + // Ensure the UI redraws to reflect the updated transcription. + self.request_redraw(); + } + + pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool { + let updated = self.bottom_pane.update_transcription_in_place(id, text); + if updated { + self.request_redraw(); + } + updated + } + + pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { + self.bottom_pane.remove_transcription_placeholder(id); + // Ensure the UI redraws to reflect placeholder removal. + self.request_redraw(); + } +} + fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool { summary.responses_api_overhead_ms > 0 || summary.responses_api_inference_time_ms > 0 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap index 9976dc6eea..3726917d26 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- " " -"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view " +"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…" " " " " "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap new file mode 100644 index 0000000000..933bc70723 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: rendered +--- +• Waiting for background terminal (0s • esc to … + └ cargo test -p codex-core -- --exact… + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d7424f720f..cc10484c35 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -325,6 +325,57 @@ async fn replayed_user_message_preserves_remote_image_urls() { assert_eq!(stored_remote_image_urls, remote_image_urls); } +#[tokio::test] +async fn session_configured_syncs_widget_config_permissions_and_cwd() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(None).await; + + chat.config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("set approval policy"); + chat.config + .permissions + .sandbox_policy + .set(SandboxPolicy::new_workspace_write_policy()) + .expect("set sandbox policy"); + chat.config.cwd = PathBuf::from("/home/user/main"); + + let expected_sandbox = SandboxPolicy::new_read_only_policy(); + let expected_cwd = PathBuf::from("/home/user/sub-agent"); + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: expected_sandbox.clone(), + cwd: expected_cwd.clone(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + + chat.handle_codex_event(Event { + id: "session-configured".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + assert_eq!( + chat.config_ref().permissions.approval_policy.value(), + AskForApproval::Never + ); + assert_eq!( + chat.config_ref().permissions.sandbox_policy.get(), + &expected_sandbox + ); + assert_eq!(&chat.config_ref().cwd, &expected_cwd); +} + #[tokio::test] async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; @@ -1577,7 +1628,7 @@ async fn make_chatwidget_manual( skills: None, }); bottom.set_steer_enabled(true); - bottom.set_collaboration_modes_enabled(cfg.features.enabled(Feature::CollaborationModes)); + bottom.set_collaboration_modes_enabled(true); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); @@ -1592,6 +1643,7 @@ async fn make_chatwidget_manual( }, }; let current_collaboration_mode = base_mode; + let active_collaboration_mask = collaboration_modes::default_mask(models_manager.as_ref()); let mut widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -1600,7 +1652,7 @@ async fn make_chatwidget_manual( active_cell_revision: 0, config: cfg, current_collaboration_mode, - active_collaboration_mask: None, + active_collaboration_mask, auth_manager, models_manager, otel_manager, @@ -3350,6 +3402,105 @@ async fn steer_enter_queues_while_plan_stream_is_active() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +async fn steer_enter_queues_while_final_answer_stream_is_active() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + // Keep the assistant stream open (no commit tick/finalize) to model the repro window: + // user presses Enter while the final answer is still streaming. + chat.on_agent_message_delta("Final answer line\n".to_string()); + + chat.bottom_pane.set_composer_text( + "queued while streaming".to_string(), + Vec::new(), + Vec::new(), + ); + 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 while streaming" + ); + assert_no_submit_op(&mut op_rx); + + // Once final output ends, the queued input must be submitted automatically. + chat.on_task_complete(None, false); + + assert!(chat.queued_user_messages.is_empty()); + match next_submit_op(&mut op_rx) { + Op::UserTurn { .. } => {} + other => panic!("expected Op::UserTurn after stream completion, got {other:?}"), + } +} + +#[tokio::test] +async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.thread_id = Some(ThreadId::new()); + chat.on_task_started(); + // Simulate "dead mode" repro timing by keeping a final-answer stream active while the + // user submits multiple follow-up prompts. + chat.on_agent_message_delta("Final answer line\n".to_string()); + + chat.bottom_pane + .set_composer_text("first follow-up".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.bottom_pane + .set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.queued_user_messages.len(), 2); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "first follow-up" + ); + assert_eq!( + chat.queued_user_messages.back().unwrap().text, + "second follow-up" + ); + assert_no_submit_op(&mut op_rx); + + // Completion must recover by submitting the oldest queued prompt first. + chat.on_task_complete(None, false); + + let first_items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + first_items, + vec![UserInput::Text { + text: "first follow-up".to_string(), + text_elements: Vec::new(), + }] + ); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages.front().unwrap().text, + "second follow-up" + ); + + // A subsequent turn lifecycle should continue draining remaining queued prompts, proving + // the widget did not enter a permanently stuck state. + chat.on_task_started(); + chat.on_task_complete(None, false); + + let second_items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + second_items, + vec![UserInput::Text { + text: "second follow-up".to_string(), + text_elements: Vec::new(), + }] + ); + assert!(chat.queued_user_messages.is_empty()); +} + #[tokio::test] async fn steer_enter_submits_when_plan_stream_is_not_active() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; @@ -3851,8 +4002,14 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { assert!(chat.active_cell.is_none()); assert_eq!( chat.current_status_header, - "Waiting for background terminal · sleep 5" + "Waiting for background terminal" ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Waiting for background terminal"); + assert_eq!(status.details(), Some("sleep 5")); } #[tokio::test] @@ -3865,8 +4022,14 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { terminal_interaction(&mut chat, "call-wait-1b", "proc-1", ""); assert_eq!( chat.current_status_header, - "Waiting for background terminal · just fix" + "Waiting for background terminal" ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Waiting for background terminal"); + assert_eq!(status.details(), Some("just fix")); chat.handle_codex_event(Event { id: "turn-wait-1".into(), @@ -3884,6 +4047,26 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { assert_snapshot!("unified_exec_waiting_multiple_empty_after", combined); } +#[tokio::test] +async fn unified_exec_wait_status_renders_command_in_single_details_row_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + begin_unified_exec_startup( + &mut chat, + "call-wait-ui", + "proc-ui", + "cargo test -p codex-core -- --exact some::very::long::test::name", + ); + + terminal_interaction(&mut chat, "call-wait-ui-stdin", "proc-ui", ""); + + let rendered = render_bottom_popup(&chat, 48); + assert_snapshot!( + "unified_exec_wait_status_renders_command_in_single_details_row", + rendered + ); +} + #[tokio::test] async fn unified_exec_empty_then_non_empty_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -3911,8 +4094,14 @@ async fn unified_exec_non_empty_then_empty_snapshots() { terminal_interaction(&mut chat, "call-wait-3b", "proc-3", ""); assert_eq!( chat.current_status_header, - "Waiting for background terminal · just fix" + "Waiting for background terminal" ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Waiting for background terminal"); + assert_eq!(status.details(), Some("just fix")); let pre_cells = drain_insert_history(&mut rx); let active_combined = pre_cells .iter() @@ -4004,17 +4193,10 @@ async fn slash_init_skips_when_project_doc_exists() { } #[tokio::test] -async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { +async fn collab_mode_shift_tab_cycles_only_when_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; - chat.set_feature_enabled(Feature::CollaborationModes, false); let initial = chat.current_collaboration_mode().clone(); - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.current_collaboration_mode(), &initial); - assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); - - chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.current_collaboration_mode(), &initial); @@ -4379,26 +4561,12 @@ async fn collab_mode_is_sent_after_enabling() { } #[tokio::test] -async fn collab_mode_toggle_on_applies_default_preset() { +async fn collab_mode_applies_default_preset() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.bottom_pane - .set_composer_text("before toggle".to_string(), Vec::new(), Vec::new()); - chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - match next_submit_op(&mut op_rx) { - Op::UserTurn { - collaboration_mode: None, - personality: Some(Personality::Pragmatic), - .. - } => {} - other => panic!("expected Op::UserTurn without collaboration_mode, got {other:?}"), - } - - chat.set_feature_enabled(Feature::CollaborationModes, true); - - chat.bottom_pane - .set_composer_text("after toggle".to_string(), Vec::new(), Vec::new()); + .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 1b16f59156..c51cc726b5 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -457,15 +457,16 @@ where /// Hard-reset scrollback + visible screen using an explicit ANSI sequence. /// - /// This is a compatibility fallback for terminals that misbehave when purge - /// and full-screen clear are issued as separate backend commands. + /// Some terminals behave more reliably when purge + clear are emitted as a + /// single ANSI sequence instead of separate backend commands. pub fn clear_scrollback_and_visible_screen_ansi(&mut self) -> io::Result<()> { if self.viewport_area.is_empty() { return Ok(()); } - // Reset scroll region + style state, purge scrollback, clear screen, home cursor. - write!(self.backend, "\x1b[r\x1b[0m\x1b[3J\x1b[2J\x1b[H")?; + // Reset scroll region + style state, home cursor, clear screen, purge scrollback. + // The order matches the common shell `clear && printf '\\e[3J'` behavior. + write!(self.backend, "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H")?; std::io::Write::flush(&mut self.backend)?; self.last_known_cursor_pos = Position { x: 0, y: 0 }; self.visible_history_rows = 0; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 87d7089591..76a7a853c9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -80,6 +80,7 @@ mod get_git_diff; mod history_cell; pub mod insert_history; mod key_hint; +mod line_truncation; pub mod live_wrap; mod markdown; mod markdown_render; @@ -113,7 +114,79 @@ pub mod update_action; mod update_prompt; mod updates; mod version; +#[cfg(all(not(target_os = "linux"), feature = "voice-input"))] +mod voice; +#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))] +mod voice { + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::AtomicU16; + pub struct RecordedAudio { + pub data: Vec, + pub sample_rate: u32, + pub channels: u16, + } + + pub struct VoiceCapture; + + pub(crate) struct RecordingMeterState; + + impl VoiceCapture { + pub fn start() -> Result { + Err("voice input is unavailable in this build".to_string()) + } + + pub fn stop(self) -> Result { + Err("voice input is unavailable in this build".to_string()) + } + + pub fn data_arc(&self) -> Arc>> { + Arc::new(Mutex::new(Vec::new())) + } + + pub fn stopped_flag(&self) -> Arc { + Arc::new(AtomicBool::new(true)) + } + + pub fn sample_rate(&self) -> u32 { + 0 + } + + pub fn channels(&self) -> u16 { + 0 + } + + pub fn last_peak_arc(&self) -> Arc { + Arc::new(AtomicU16::new(0)) + } + } + + impl RecordingMeterState { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) fn next_text(&mut self, _peak: u16) -> String { + "⠤⠤⠤⠤".to_string() + } + } + + pub fn transcribe_async( + id: String, + _audio: RecordedAudio, + _context: Option, + tx: AppEventSender, + ) { + tx.send(AppEvent::TranscriptionFailed { + id, + error: "voice input is unavailable in this build".to_string(), + }); + } +} mod wrapping; #[cfg(test)] diff --git a/codex-rs/tui/src/line_truncation.rs b/codex-rs/tui/src/line_truncation.rs new file mode 100644 index 0000000000..d8a9408e37 --- /dev/null +++ b/codex-rs/tui/src/line_truncation.rs @@ -0,0 +1,100 @@ +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +pub(crate) fn line_width(line: &Line<'_>) -> usize { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + let Line { + style, + alignment, + spans, + } = line; + let mut used = 0usize; + let mut spans_out: Vec> = Vec::with_capacity(spans.len()); + + for span in spans { + let span_width = UnicodeWidthStr::width(span.content.as_ref()); + + if span_width == 0 { + spans_out.push(span); + continue; + } + + if used >= max_width { + break; + } + + if used + span_width <= max_width { + used += span_width; + spans_out.push(span); + continue; + } + + let style = span.style; + let text = span.content.as_ref(); + let mut end_idx = 0usize; + for (idx, ch) in text.char_indices() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > max_width { + break; + } + end_idx = idx + ch.len_utf8(); + used += ch_width; + } + + if end_idx > 0 { + spans_out.push(Span::styled(text[..end_idx].to_string(), style)); + } + + break; + } + + Line { + style, + alignment, + spans: spans_out, + } +} + +/// Truncate a styled line to `max_width` and append an ellipsis on overflow. +/// +/// Intended for short UI rows. This preserves a fast no-overflow path (width +/// pre-scan + return original line unchanged) and uses `truncate_line_to_width` +/// for the overflow case. +/// Performance should be reevaluated if using this method in loops/over larger content in the future. +pub(crate) fn truncate_line_with_ellipsis_if_overflow( + line: Line<'static>, + max_width: usize, +) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + if line_width(&line) <= max_width { + return line; + } + + let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); + let Line { + style, + alignment, + mut spans, + } = truncated; + let ellipsis_style = spans.last().map(|span| span.style).unwrap_or_default(); + spans.push(Span::styled("…", ellipsis_style)); + Line { + style, + alignment, + spans, + } +} diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 806400f2e3..48eaab7344 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -22,6 +22,13 @@ const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentPickerThreadEntry { + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, + pub(crate) is_closed: bool, +} + #[derive(Clone, Copy)] struct AgentLabel<'a> { thread_id: Option, @@ -29,6 +36,44 @@ struct AgentLabel<'a> { role: Option<&'a str>, } +pub(crate) fn agent_picker_status_dot_spans(is_closed: bool) -> Vec> { + let dot = if is_closed { + "•".dark_gray() + } else { + "•".green() + }; + vec![dot, " ".into()] +} + +pub(crate) fn format_agent_picker_item_name( + agent_nickname: Option<&str>, + agent_role: Option<&str>, + is_primary: bool, +) -> String { + if is_primary { + return "Main [default]".to_string(); + } + + let agent_nickname = agent_nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); + match (agent_nickname, agent_role) { + (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), + (Some(agent_nickname), None) => agent_nickname.to_string(), + (None, Some(agent_role)) => format!("[{agent_role}]"), + (None, None) => "Agent".to_string(), + } +} + +pub(crate) fn sort_agent_picker_threads(agent_threads: &mut [(ThreadId, AgentPickerThreadEntry)]) { + agent_threads.sort_by(|(left_id, left), (right_id, right)| { + left.is_closed + .cmp(&right.is_closed) + .then_with(|| left_id.to_string().cmp(&right_id.to_string())) + }); +} + pub(crate) fn spawn_end(ev: CollabAgentSpawnEndEvent) -> PlainHistoryCell { let CollabAgentSpawnEndEvent { call_id: _, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index a512060e2b..e4ee2dc763 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -68,7 +68,7 @@ impl SlashCommand { SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", - SlashCommand::Clear => "clear the terminal screen and scrollback", + SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap index ba179808bd..7f34188617 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap @@ -2,5 +2,5 @@ source: tui/src/status_indicator_widget.rs expression: terminal.backend() --- -"• Working (0s • esc " +"• Working (0s • esc…" " " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index fa266f9760..cb4f9c5ab4 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -23,6 +23,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::exec_cell::spinner; use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; use crate::text_formatting::capitalize_first; @@ -30,14 +31,21 @@ use crate::tui::FrameRequester; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; -const DETAILS_MAX_LINES: usize = 3; +pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3; const DETAILS_PREFIX: &str = " └ "; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StatusDetailsCapitalization { + CapitalizeFirst, + Preserve, +} + /// Displays a single-line in-progress status with optional wrapped details. pub(crate) struct StatusIndicatorWidget { /// Animated header text (defaults to "Working"). header: String, details: Option, + details_max_lines: usize, /// Optional suffix rendered after the elapsed/interrupt segment. inline_message: Option, show_interrupt_hint: bool, @@ -76,6 +84,7 @@ impl StatusIndicatorWidget { Self { header: String::from("Working"), details: None, + details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, inline_message: None, show_interrupt_hint: true, elapsed_running: Duration::ZERO, @@ -98,10 +107,22 @@ impl StatusIndicatorWidget { } /// Update the details text shown below the header. - pub(crate) fn update_details(&mut self, details: Option) { + pub(crate) fn update_details( + &mut self, + details: Option, + capitalization: StatusDetailsCapitalization, + max_lines: usize, + ) { + self.details_max_lines = max_lines.max(1); self.details = details .filter(|details| !details.is_empty()) - .map(|details| capitalize_first(details.trim_start())); + .map(|details| { + let trimmed = details.trim_start(); + match capitalization { + StatusDetailsCapitalization::CapitalizeFirst => capitalize_first(trimmed), + StatusDetailsCapitalization::Preserve => trimmed.to_string(), + } + }); } /// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`. @@ -192,8 +213,8 @@ impl StatusIndicatorWidget { let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts); - if out.len() > DETAILS_MAX_LINES { - out.truncate(DETAILS_MAX_LINES); + if out.len() > self.details_max_lines { + out.truncate(self.details_max_lines); let content_width = usize::from(width).saturating_sub(prefix_width).max(1); let max_base_len = content_width.saturating_sub(1); if let Some(last) = out.last_mut() @@ -253,7 +274,10 @@ impl Renderable for StatusIndicatorWidget { } let mut lines = Vec::new(); - lines.push(Line::from(spans)); + lines.push(truncate_line_with_ellipsis_if_overflow( + Line::from(spans), + usize::from(area.width), + )); if area.height > 1 { // If there is enough space, add the details lines below the header. let details = self.wrapped_details_lines(area.width); @@ -325,7 +349,11 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); - w.update_details(Some("A man a plan a canal panama".to_string())); + w.update_details( + Some("A man a plan a canal panama".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); w.set_interrupt_hint_visible(false); // Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable. @@ -368,14 +396,45 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); - w.update_details(Some("abcd abcd abcd abcd".to_string())); + w.update_details( + Some("abcd abcd abcd abcd".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); let lines = w.wrapped_details_lines(6); - assert_eq!(lines.len(), DETAILS_MAX_LINES); + assert_eq!(lines.len(), STATUS_DETAILS_DEFAULT_MAX_LINES); let last = lines.last().expect("expected last details line"); assert!( last.spans[1].content.as_ref().ends_with("…"), "expected ellipsis in last line: {last:?}" ); } + + #[test] + fn details_args_can_disable_capitalization_and_limit_lines() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + w.update_details( + Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()), + StatusDetailsCapitalization::Preserve, + 1, + ); + + assert_eq!( + w.details(), + Some("cargo test -p codex-core and then cargo test -p codex-tui") + ); + + let lines = w.wrapped_details_lines(24); + assert_eq!(lines.len(), 1); + let last = lines.last().expect("expected one details line"); + assert!( + last.spans + .last() + .is_some_and(|span| span.content.as_ref().contains('…')), + "expected one-line details to be ellipsized, got {last:?}" + ); + } } diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs new file mode 100644 index 0000000000..6852fa6ff3 --- /dev/null +++ b/codex-rs/tui/src/voice.rs @@ -0,0 +1,517 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::config::Config; +use codex_core::config::find_codex_home; +use codex_core::default_client::get_codex_user_agent; +use codex_login::AuthMode; +use codex_login::CodexAuth; +use cpal::traits::DeviceTrait; +use cpal::traits::HostTrait; +use cpal::traits::StreamTrait; +use hound::SampleFormat; +use hound::WavSpec; +use hound::WavWriter; +use std::collections::VecDeque; +use std::io::Cursor; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU16; +use std::sync::atomic::Ordering; +use tracing::error; +use tracing::info; +use tracing::trace; + +struct TranscriptionAuthContext { + mode: AuthMode, + bearer_token: String, + chatgpt_account_id: Option, + chatgpt_base_url: String, +} + +pub struct RecordedAudio { + pub data: Vec, + pub sample_rate: u32, + pub channels: u16, +} + +pub struct VoiceCapture { + stream: Option, + sample_rate: u32, + channels: u16, + data: Arc>>, + stopped: Arc, + last_peak: Arc, +} + +impl VoiceCapture { + pub fn start() -> Result { + let (device, config) = select_input_device_and_config()?; + + let sample_rate = config.sample_rate().0; + let channels = config.channels(); + let data: Arc>> = Arc::new(Mutex::new(Vec::new())); + let stopped = Arc::new(AtomicBool::new(false)); + let last_peak = Arc::new(AtomicU16::new(0)); + + let stream = build_input_stream(&device, &config, data.clone(), last_peak.clone())?; + stream + .play() + .map_err(|e| format!("failed to start input stream: {e}"))?; + + Ok(Self { + stream: Some(stream), + sample_rate, + channels, + data, + stopped, + last_peak, + }) + } + + pub fn stop(mut self) -> Result { + // Mark stopped so any metering task can exit cleanly. + self.stopped.store(true, Ordering::SeqCst); + // Dropping the stream stops capture. + self.stream.take(); + let data = self + .data + .lock() + .map_err(|_| "failed to lock audio buffer".to_string())? + .clone(); + Ok(RecordedAudio { + data, + sample_rate: self.sample_rate, + channels: self.channels, + }) + } + + pub fn data_arc(&self) -> Arc>> { + self.data.clone() + } + + pub fn stopped_flag(&self) -> Arc { + self.stopped.clone() + } + + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> u16 { + self.channels + } + + pub fn last_peak_arc(&self) -> Arc { + self.last_peak.clone() + } +} + +pub(crate) struct RecordingMeterState { + history: VecDeque, + noise_ema: f64, + env: f64, +} + +impl RecordingMeterState { + pub(crate) fn new() -> Self { + let mut history = VecDeque::with_capacity(4); + while history.len() < 4 { + history.push_back('⠤'); + } + Self { + history, + noise_ema: 0.02, + env: 0.0, + } + } + + pub(crate) fn next_text(&mut self, peak: u16) -> String { + const SYMBOLS: [char; 7] = ['⠤', '⠴', '⠶', '⠷', '⡷', '⡿', '⣿']; + const ALPHA_NOISE: f64 = 0.05; + const ATTACK: f64 = 0.80; + const RELEASE: f64 = 0.25; + + let latest_peak = peak as f64 / (i16::MAX as f64); + + if latest_peak > self.env { + self.env = ATTACK * latest_peak + (1.0 - ATTACK) * self.env; + } else { + self.env = RELEASE * latest_peak + (1.0 - RELEASE) * self.env; + } + + let rms_approx = self.env * 0.7; + self.noise_ema = (1.0 - ALPHA_NOISE) * self.noise_ema + ALPHA_NOISE * rms_approx; + let ref_level = self.noise_ema.max(0.01); + let fast_signal = 0.8 * latest_peak + 0.2 * self.env; + let target = 2.0f64; + let raw = (fast_signal / (ref_level * target)).max(0.0); + let k = 1.6f64; + let compressed = (raw.ln_1p() / k.ln_1p()).min(1.0); + let idx = (compressed * (SYMBOLS.len() as f64 - 1.0)) + .round() + .clamp(0.0, SYMBOLS.len() as f64 - 1.0) as usize; + let level_char = SYMBOLS[idx]; + + if self.history.len() >= 4 { + self.history.pop_front(); + } + self.history.push_back(level_char); + + let mut text = String::with_capacity(4); + for ch in &self.history { + text.push(*ch); + } + text + } +} + +pub fn transcribe_async( + id: String, + audio: RecordedAudio, + context: Option, + tx: AppEventSender, +) { + std::thread::spawn(move || { + // Enforce minimum duration to avoid garbage outputs. + const MIN_DURATION_SECONDS: f32 = 1.0; + let duration_seconds = clip_duration_seconds(&audio); + if duration_seconds < MIN_DURATION_SECONDS { + let msg = format!( + "recording too short ({duration_seconds:.2}s); minimum is {MIN_DURATION_SECONDS:.2}s" + ); + info!("{msg}"); + tx.send(AppEvent::TranscriptionFailed { id, error: msg }); + return; + } + + // Encode entire clip as normalized WAV. + let wav_bytes = match encode_wav_normalized(&audio) { + Ok(b) => b, + Err(e) => { + error!("failed to encode wav: {e}"); + tx.send(AppEvent::TranscriptionFailed { id, error: e }); + return; + } + }; + + // Run the HTTP request on a small, dedicated runtime. + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + error!("failed to create tokio runtime: {e}"); + return; + } + }; + + let tx2 = tx.clone(); + let id2 = id.clone(); + let res: Result = rt + .block_on(async move { transcribe_bytes(wav_bytes, context, duration_seconds).await }); + + match res { + Ok(text) => { + tx2.send(AppEvent::TranscriptionComplete { id: id2, text }); + info!("voice transcription succeeded"); + } + Err(e) => { + error!("voice transcription error: {e}"); + tx.send(AppEvent::TranscriptionFailed { id, error: e }); + } + } + }); +} + +// ------------------------- +// Voice input helpers +// ------------------------- + +fn select_input_device_and_config() -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { + let host = cpal::default_host(); + let device = host + .default_input_device() + .ok_or_else(|| "no input audio device available".to_string())?; + let config = device + .default_input_config() + .map_err(|e| format!("failed to get default input config: {e}"))?; + Ok((device, config)) +} + +fn build_input_stream( + device: &cpal::Device, + config: &cpal::SupportedStreamConfig, + data: Arc>>, + last_peak: Arc, +) -> Result { + match config.sample_format() { + cpal::SampleFormat::F32 => device + .build_input_stream( + &config.clone().into(), + move |input: &[f32], _| { + let peak = peak_f32(input); + last_peak.store(peak, Ordering::Relaxed); + if let Ok(mut buf) = data.lock() { + for &s in input { + buf.push(f32_to_i16(s)); + } + } + }, + move |err| error!("audio input error: {err}"), + None, + ) + .map_err(|e| format!("failed to build input stream: {e}")), + cpal::SampleFormat::I16 => device + .build_input_stream( + &config.clone().into(), + move |input: &[i16], _| { + let peak = peak_i16(input); + last_peak.store(peak, Ordering::Relaxed); + if let Ok(mut buf) = data.lock() { + buf.extend_from_slice(input); + } + }, + move |err| error!("audio input error: {err}"), + None, + ) + .map_err(|e| format!("failed to build input stream: {e}")), + cpal::SampleFormat::U16 => device + .build_input_stream( + &config.clone().into(), + move |input: &[u16], _| { + if let Ok(mut buf) = data.lock() { + let peak = convert_u16_to_i16_and_peak(input, &mut buf); + last_peak.store(peak, Ordering::Relaxed); + } + }, + move |err| error!("audio input error: {err}"), + None, + ) + .map_err(|e| format!("failed to build input stream: {e}")), + _ => Err("unsupported input sample format".to_string()), + } +} + +#[inline] +fn f32_abs_to_u16(x: f32) -> u16 { + let peak_u = (x.abs().min(1.0) * i16::MAX as f32) as i32; + peak_u.max(0) as u16 +} + +#[inline] +fn f32_to_i16(s: f32) -> i16 { + (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16 +} + +fn peak_f32(input: &[f32]) -> u16 { + let mut peak: f32 = 0.0; + for &s in input { + let a = s.abs(); + if a > peak { + peak = a; + } + } + f32_abs_to_u16(peak) +} + +fn peak_i16(input: &[i16]) -> u16 { + let mut peak: i32 = 0; + for &s in input { + let a = (s as i32).unsigned_abs() as i32; + if a > peak { + peak = a; + } + } + peak as u16 +} + +fn convert_u16_to_i16_and_peak(input: &[u16], out: &mut Vec) -> u16 { + let mut peak: i32 = 0; + for &s in input { + let v_i16 = (s as i32 - 32768) as i16; + let a = (v_i16 as i32).unsigned_abs() as i32; + if a > peak { + peak = a; + } + out.push(v_i16); + } + peak as u16 +} + +// ------------------------- +// Transcription helpers +// ------------------------- + +fn clip_duration_seconds(audio: &RecordedAudio) -> f32 { + let total_samples = audio.data.len() as f32; + let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32); + if samples_per_second > 0.0 { + total_samples / samples_per_second + } else { + 0.0 + } +} + +fn encode_wav_normalized(audio: &RecordedAudio) -> Result, String> { + let mut wav_bytes: Vec = Vec::new(); + let spec = WavSpec { + channels: audio.channels, + sample_rate: audio.sample_rate, + bits_per_sample: 16, + sample_format: SampleFormat::Int, + }; + let mut cursor = Cursor::new(&mut wav_bytes); + let mut writer = + WavWriter::new(&mut cursor, spec).map_err(|_| "failed to create wav writer".to_string())?; + + // Simple peak normalization with headroom to improve audibility on quiet inputs. + let segment = &audio.data[..]; + let mut peak: i16 = 0; + for &s in segment { + let a = s.unsigned_abs(); + if a > peak.unsigned_abs() { + peak = s; + } + } + let peak_abs = (peak as i32).unsigned_abs() as i32; + let target = (i16::MAX as f32) * 0.9; // leave some headroom + let gain: f32 = if peak_abs > 0 { + target / (peak_abs as f32) + } else { + 1.0 + }; + + for &s in segment { + let v = ((s as f32) * gain) + .round() + .clamp(i16::MIN as f32, i16::MAX as f32) as i16; + writer + .write_sample(v) + .map_err(|_| "failed writing wav sample".to_string())?; + } + writer + .finalize() + .map_err(|_| "failed to finalize wav".to_string())?; + Ok(wav_bytes) +} + +fn normalize_chatgpt_base_url(input: &str) -> String { + let mut base_url = input.to_string(); + while base_url.ends_with('/') { + base_url.pop(); + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + base_url +} + +async fn resolve_auth() -> Result { + let codex_home = find_codex_home().map_err(|e| format!("failed to find codex home: {e}"))?; + let auth = CodexAuth::from_auth_storage(&codex_home, AuthCredentialsStoreMode::Auto) + .map_err(|e| format!("failed to read auth.json: {e}"))? + .ok_or_else(|| "No Codex auth is configured; please run `codex login`".to_string())?; + + let chatgpt_account_id = auth.get_account_id(); + + let token = auth + .get_token() + .map_err(|e| format!("failed to get auth token: {e}"))?; + let config = Config::load_with_cli_overrides(Vec::new()) + .await + .map_err(|e| format!("failed to load config: {e}"))?; + Ok(TranscriptionAuthContext { + mode: auth.api_auth_mode(), + bearer_token: token, + chatgpt_account_id, + chatgpt_base_url: normalize_chatgpt_base_url(&config.chatgpt_base_url), + }) +} + +async fn transcribe_bytes( + wav_bytes: Vec, + context: Option, + duration_seconds: f32, +) -> Result { + let auth = resolve_auth().await?; + let client = reqwest::Client::new(); + let audio_bytes = wav_bytes.len(); + let prompt_for_log = context.as_deref().unwrap_or("").to_string(); + let (endpoint, request) = + if matches!(auth.mode, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) { + let part = reqwest::multipart::Part::bytes(wav_bytes) + .file_name("audio.wav") + .mime_str("audio/wav") + .map_err(|e| format!("failed to set mime: {e}"))?; + let form = reqwest::multipart::Form::new().part("file", part); + let endpoint = format!("{}/transcribe", auth.chatgpt_base_url); + let mut req = client + .post(&endpoint) + .bearer_auth(&auth.bearer_token) + .multipart(form) + .header("User-Agent", get_codex_user_agent()); + if let Some(acc) = auth.chatgpt_account_id { + req = req.header("ChatGPT-Account-Id", acc); + } + (endpoint, req) + } else { + let part = reqwest::multipart::Part::bytes(wav_bytes) + .file_name("audio.wav") + .mime_str("audio/wav") + .map_err(|e| format!("failed to set mime: {e}"))?; + let mut form = reqwest::multipart::Form::new() + .text("model", "gpt-4o-transcribe") + .part("file", part); + if let Some(context) = context { + form = form.text("prompt", context); + } + let endpoint = "https://api.openai.com/v1/audio/transcriptions".to_string(); + ( + endpoint, + client + .post("https://api.openai.com/v1/audio/transcriptions") + .bearer_auth(&auth.bearer_token) + .multipart(form) + .header("User-Agent", get_codex_user_agent()), + ) + }; + + let audio_kib = audio_bytes as f32 / 1024.0; + let mode = auth.mode; + trace!( + "sending transcription request: mode={mode:?} endpoint={endpoint} duration={duration_seconds:.2}s audio={audio_kib:.1}KiB prompt={prompt_for_log}" + ); + + let resp = request + .send() + .await + .map_err(|e| format!("transcription request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!("transcription failed: {status} {body}")); + } + + let v: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("failed to parse json: {e}"))?; + let text = v + .get("text") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + + if text.is_empty() { + Err("empty transcription result".to_string()) + } else { + Ok(text) + } +} diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index f042fbbc9c..960b8fed54 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -44,7 +44,7 @@ where let opts = width_or_options.into(); let mut lines: Vec> = Vec::new(); let mut cursor = 0usize; - for line in textwrap::wrap(text, opts).iter() { + for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() { match line { std::borrow::Cow::Borrowed(slice) => { let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; @@ -54,7 +54,12 @@ where cursor = end + trailing_spaces; } std::borrow::Cow::Owned(slice) => { - let mapped = map_owned_wrapped_line_to_range(text, cursor, slice); + let synthetic_prefix = if line_index == 0 { + opts.initial_indent + } else { + opts.subsequent_indent + }; + let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix); let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count(); lines.push(mapped.start..mapped.end + trailing_spaces + 1); cursor = mapped.end + trailing_spaces; @@ -74,7 +79,7 @@ where let opts = width_or_options.into(); let mut lines: Vec> = Vec::new(); let mut cursor = 0usize; - for line in textwrap::wrap(text, opts).iter() { + for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() { match line { std::borrow::Cow::Borrowed(slice) => { let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; @@ -83,7 +88,12 @@ where cursor = end; } std::borrow::Cow::Owned(slice) => { - let mapped = map_owned_wrapped_line_to_range(text, cursor, slice); + let synthetic_prefix = if line_index == 0 { + opts.initial_indent + } else { + opts.subsequent_indent + }; + let mapped = map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix); lines.push(mapped.clone()); cursor = mapped.end; } @@ -99,7 +109,18 @@ where /// function walks the owned string character-by-character against the /// source, skipping trailing penalty chars, and returns the /// corresponding source byte range starting from `cursor`. -fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> Range { +fn map_owned_wrapped_line_to_range( + text: &str, + cursor: usize, + wrapped: &str, + synthetic_prefix: &str, +) -> Range { + let wrapped = if synthetic_prefix.is_empty() { + wrapped + } else { + wrapped.strip_prefix(synthetic_prefix).unwrap_or(wrapped) + }; + let mut start = cursor; while start < text.len() && !wrapped.starts_with(' ') { let Some(ch) = text[start..].chars().next() else { @@ -112,6 +133,7 @@ fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> } let mut end = start; + let mut saw_source_char = false; let mut chars = wrapped.chars().peekable(); while let Some(ch) = chars.next() { if end < text.len() { @@ -120,6 +142,7 @@ fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> }; if ch == src { end += src.len_utf8(); + saw_source_char = true; continue; } } @@ -131,7 +154,20 @@ fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> continue; } - panic!("wrap_ranges: could not map owned line {wrapped:?} to source near byte {cursor}"); + // Non-source chars can be synthesized by textwrap in owned output + // (e.g. non-space indent prefixes). Keep going and map the source bytes + // we can confidently match instead of crashing the app. + if !saw_source_char { + continue; + } + + tracing::warn!( + wrapped = %wrapped, + cursor, + end, + "wrap_ranges: could not fully map owned line; returning partial source range" + ); + break; } start..end @@ -1229,6 +1265,124 @@ them."# ); } + #[test] + fn map_owned_wrapped_line_to_range_recovers_on_non_prefix_mismatch() { + // Match source chars first, then introduce a non-penalty mismatch. + // The function should recover and return the mapped prefix range. + let range = map_owned_wrapped_line_to_range("hello world", 0, "helloX", ""); + assert_eq!(range, 0..5); + } + + #[test] + fn map_owned_wrapped_line_to_range_indent_coincides_with_source() { + // When the synthetic indent prefix starts with a character that also + // appears at the current source position, the mapper must not confuse + // the indent char for a source match. Here the indent is "- " and the + // source text also starts with "-", so a naive char-by-char match would + // consume the source "-" for the indent "-", set saw_source_char too + // early, then break on the space — returning 0..1 instead of the full + // first word. + let text = "- item one and some more words"; + // Simulate what textwrap would produce for the first continuation line + // when subsequent_indent = "- ": it prepends "- " to the source slice. + let range = map_owned_wrapped_line_to_range(text, 0, "- - item one", "- "); + // The mapper should skip the synthetic "- " prefix and map "- item one" + // back to source bytes 0..10. + assert_eq!(range, 0..10); + } + + #[test] + fn wrap_ranges_indent_prefix_coincides_with_source_char() { + // End-to-end: source text starts with the same character as the indent + // prefix. wrap_ranges must still reconstruct the full source. + let text = "- first item is long enough to wrap around"; + let opts = || { + textwrap::Options::new(16) + .initial_indent("- ") + .subsequent_indent("- ") + }; + let ranges = wrap_ranges(text, opts()); + assert!(!ranges.is_empty()); + + let mut rebuilt = String::new(); + let mut cursor = 0usize; + for range in ranges { + let start = range.start.max(cursor).min(text.len()); + let end = range.end.min(text.len()); + if start < end { + rebuilt.push_str(&text[start..end]); + } + cursor = cursor.max(end); + } + assert_eq!(rebuilt, text); + } + + #[test] + fn map_owned_wrapped_line_to_range_repro_overconsumes_repeated_prefix_patterns() { + let text = "- - foo"; + let opts = textwrap::Options::new(3) + .initial_indent("- ") + .subsequent_indent("- ") + .word_separator(textwrap::WordSeparator::AsciiSpace) + .break_words(false); + let wrapped = textwrap::wrap(text, opts); + let Some(line) = wrapped.first() else { + panic!("expected at least one wrapped line"); + }; + + let mapped = map_owned_wrapped_line_to_range(text, 0, line.as_ref(), "- "); + let expected_len = line + .as_ref() + .strip_prefix("- ") + .unwrap_or(line.as_ref()) + .len(); + let mapped_len = mapped.end.saturating_sub(mapped.start); + assert!( + mapped_len <= expected_len, + "overconsumed source: text={text:?} line={line:?} mapped={mapped:?} expected_len={expected_len}" + ); + } + + #[test] + fn wrap_ranges_recovers_with_non_space_indents() { + let text = "The quick brown fox jumps over the lazy dog"; + let wrapped = textwrap::wrap( + text, + textwrap::Options::new(12) + .initial_indent("* ") + .subsequent_indent(" "), + ); + assert!( + wrapped + .iter() + .any(|line| matches!(line, std::borrow::Cow::Owned(_))), + "expected textwrap to produce owned lines with synthetic indent prefixes" + ); + + let ranges = wrap_ranges( + text, + textwrap::Options::new(12) + .initial_indent("* ") + .subsequent_indent(" "), + ); + assert!(!ranges.is_empty()); + + // wrap_ranges returns cursor-oriented ranges that may overlap by one byte; + // rebuild with cursor progression to validate full source coverage. + let mut rebuilt = String::new(); + let mut cursor = 0usize; + for range in ranges { + let start = range.start.max(cursor).min(text.len()); + let end = range.end.min(text.len()); + if start < end { + rebuilt.push_str(&text[start..end]); + } + cursor = cursor.max(end); + } + + assert_eq!(rebuilt, text); + } + #[test] fn wrap_ranges_trim_handles_owned_lines_with_penalty_char() { fn split_every_char(word: &str) -> Vec { diff --git a/codex-rs/utils/string/Cargo.toml b/codex-rs/utils/string/Cargo.toml index 8a62539382..3f5890cfb1 100644 --- a/codex-rs/utils/string/Cargo.toml +++ b/codex-rs/utils/string/Cargo.toml @@ -7,5 +7,8 @@ license.workspace = true [lints] workspace = true +[dependencies] +regex-lite = { workspace = true } + [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/utils/string/src/lib.rs b/codex-rs/utils/string/src/lib.rs index b69f185649..df9ae59e1b 100644 --- a/codex-rs/utils/string/src/lib.rs +++ b/codex-rs/utils/string/src/lib.rs @@ -62,11 +62,54 @@ pub fn sanitize_metric_tag_value(value: &str) -> String { } } +/// Find all UUIDs in a string. +#[allow(clippy::unwrap_used)] +pub fn find_uuids(s: &str) -> Vec { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + regex_lite::Regex::new( + r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + ) + .unwrap() // Unwrap is safe thanks to the tests. + }); + + re.find_iter(s).map(|m| m.as_str().to_string()).collect() +} + #[cfg(test)] mod tests { + use super::find_uuids; use super::sanitize_metric_tag_value; use pretty_assertions::assert_eq; + #[test] + fn find_uuids_finds_multiple() { + let input = + "x 00112233-4455-6677-8899-aabbccddeeff-k y 12345678-90ab-cdef-0123-456789abcdef"; + assert_eq!( + find_uuids(input), + vec![ + "00112233-4455-6677-8899-aabbccddeeff".to_string(), + "12345678-90ab-cdef-0123-456789abcdef".to_string(), + ] + ); + } + + #[test] + fn find_uuids_ignores_invalid() { + let input = "not-a-uuid-1234-5678-9abc-def0-123456789abc"; + assert_eq!(find_uuids(input), Vec::::new()); + } + + #[test] + fn find_uuids_handles_non_ascii_without_overlap() { + let input = "🙂 55e5d6f7-8a7f-4d2a-8d88-123456789012abc"; + assert_eq!( + find_uuids(input), + vec!["55e5d6f7-8a7f-4d2a-8d88-123456789012".to_string()] + ); + } + #[test] fn sanitize_metric_tag_value_trims_and_fills_unspecified() { let msg = "///"; diff --git a/patches/toolchains_llvm_bootstrapped_resource_dir.patch b/patches/toolchains_llvm_bootstrapped_resource_dir.patch index 096b3003a7..ab03782802 100644 --- a/patches/toolchains_llvm_bootstrapped_resource_dir.patch +++ b/patches/toolchains_llvm_bootstrapped_resource_dir.patch @@ -71,15 +71,3 @@ index 6ffc9f7..e02089a 100644 ], }), ) -diff --git a/toolchain/llvm/llvm.bzl b/toolchain/llvm/llvm.bzl -index d068085..c152552 100644 ---- a/toolchain/llvm/llvm.bzl -+++ b/toolchain/llvm/llvm.bzl -@@ -7,6 +7,7 @@ def declare_llvm_targets(*, suffix = ""): - name = "builtin_headers", - # Grab whichever version-specific dir is there. - path = native.glob(["lib/clang/*"], exclude_directories = 0)[0] + "/include", -+ visibility = ["//visibility:public"], - ) - - # Convenient exports