diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 34bbd40b19..f7b1ffaa59 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -60,8 +60,15 @@ runs: # Use the shortest available drive to reduce argv/path length issues, # but avoid the drive root because some Windows test launchers mis-handle # MANIFEST paths there. - $bazelOutputUserRoot = if (Test-Path 'D:\') { 'D:\b' } else { 'C:\b' } + $hasDDrive = Test-Path 'D:\' + $bazelOutputUserRoot = if ($hasDDrive) { 'D:\b' } else { 'C:\b' } + $repoContentsCache = Join-Path $env:RUNNER_TEMP "bazel-repo-contents-cache-$env:GITHUB_RUN_ID-$env:GITHUB_JOB" "BAZEL_OUTPUT_USER_ROOT=$bazelOutputUserRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "BAZEL_REPO_CONTENTS_CACHE=$repoContentsCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + if (-not $hasDDrive) { + $repositoryCache = Join-Path $env:USERPROFILE '.cache\bazel-repo-cache' + "BAZEL_REPOSITORY_CACHE=$repositoryCache" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } - name: Enable Git long paths (Windows) if: runner.os == 'Windows' diff --git a/.github/scripts/run-argument-comment-lint-bazel.sh b/.github/scripts/run-argument-comment-lint-bazel.sh new file mode 100755 index 0000000000..63de5d487f --- /dev/null +++ b/.github/scripts/run-argument-comment-lint-bazel.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ci_config=ci-linux +case "${RUNNER_OS:-}" in + macOS) + ci_config=ci-macos + ;; + Windows) + ci_config=ci-windows + ;; +esac + +bazel_lint_args=("$@") +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + has_host_platform_override=0 + for arg in "${bazel_lint_args[@]}"; do + if [[ "$arg" == --host_platform=* ]]; then + has_host_platform_override=1 + break + fi + done + + if [[ $has_host_platform_override -eq 0 ]]; then + # The nightly Windows lint toolchain is registered with an MSVC exec + # platform even though the lint target platform stays on `windows-gnullvm`. + # Override the host platform here so the exec-side helper binaries actually + # match the registered toolchain set. + bazel_lint_args+=("--host_platform=//:local_windows_msvc") + fi + + # Native Windows lint runs need exec-side Rust helper binaries and proc-macros + # to use rust-lld instead of the C++ linker path. The default `none` + # preference resolves to `cc` when a cc_toolchain is present, which currently + # routes these exec actions through clang++ with an argument shape it cannot + # consume. + bazel_lint_args+=("--@rules_rust//rust/settings:toolchain_linker_preference=rust") +fi + +bazel_startup_args=() +if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then + bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") +fi + +run_bazel() { + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + MSYS2_ARG_CONV_EXCL='*' bazel "$@" + return + fi + + bazel "$@" +} + +run_bazel_with_startup_args() { + if [[ ${#bazel_startup_args[@]} -gt 0 ]]; then + run_bazel "${bazel_startup_args[@]}" "$@" + return + fi + + run_bazel "$@" +} + +read_query_labels() { + local query="$1" + local query_stdout + local query_stderr + query_stdout="$(mktemp)" + query_stderr="$(mktemp)" + + if ! run_bazel_with_startup_args \ + --noexperimental_remote_repo_contents_cache \ + query \ + --keep_going \ + --output=label \ + "$query" >"$query_stdout" 2>"$query_stderr"; then + cat "$query_stderr" >&2 + rm -f "$query_stdout" "$query_stderr" + exit 1 + fi + + cat "$query_stdout" + rm -f "$query_stdout" "$query_stderr" +} + +final_build_targets=(//codex-rs/...) +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + # Bazel's local Windows platform currently lacks a default test toolchain for + # `rust_test`, so target the concrete Rust crate rules directly. The lint + # aspect still walks their crate graph, which preserves incremental reuse for + # non-test code while avoiding non-Rust wrapper targets such as platform_data. + final_build_targets=() + while IFS= read -r label; do + [[ -n "$label" ]] || continue + final_build_targets+=("$label") + done < <(read_query_labels 'kind("rust_(library|binary|proc_macro) rule", //codex-rs/...)') + + if [[ ${#final_build_targets[@]} -eq 0 ]]; then + echo "Failed to discover Windows Bazel lint targets." >&2 + exit 1 + fi +fi + +./.github/scripts/run-bazel-ci.sh \ + -- \ + build \ + "${bazel_lint_args[@]}" \ + -- \ + "${final_build_targets[@]}" diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index cf50135c2e..92abd0df2e 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -41,6 +41,15 @@ if [[ -n "${BAZEL_OUTPUT_USER_ROOT:-}" ]]; then bazel_startup_args+=("--output_user_root=${BAZEL_OUTPUT_USER_ROOT}") fi +run_bazel() { + if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + MSYS2_ARG_CONV_EXCL='*' bazel "$@" + return + fi + + bazel "$@" +} + ci_config=ci-linux case "${RUNNER_OS:-}" in macOS) @@ -60,7 +69,7 @@ print_bazel_test_log_tails() { bazel_info_cmd+=("${bazel_startup_args[@]}") fi - testlogs_dir="$("${bazel_info_cmd[@]}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" + testlogs_dir="$(run_bazel "${bazel_info_cmd[@]:1}" info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" local failed_targets=() while IFS= read -r target; do @@ -126,6 +135,17 @@ if [[ $remote_download_toplevel -eq 1 ]]; then post_config_bazel_args+=(--remote_download_toplevel) fi +if [[ -n "${BAZEL_REPO_CONTENTS_CACHE:-}" ]]; then + # Windows self-hosted runners can run multiple Bazel jobs concurrently. Give + # each job its own repo contents cache so they do not fight over the shared + # path configured in `ci-windows`. + post_config_bazel_args+=("--repo_contents_cache=${BAZEL_REPO_CONTENTS_CACHE}") +fi + +if [[ -n "${BAZEL_REPOSITORY_CACHE:-}" ]]; then + post_config_bazel_args+=("--repository_cache=${BAZEL_REPOSITORY_CACHE}") +fi + bazel_console_log="$(mktemp)" trap 'rm -f "$bazel_console_log"' EXIT @@ -149,7 +169,7 @@ if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then bazel_run_args+=("${post_config_bazel_args[@]}") fi set +e - "${bazel_cmd[@]}" \ + run_bazel "${bazel_cmd[@]:1}" \ --noexperimental_remote_repo_contents_cache \ "${bazel_run_args[@]}" \ -- \ @@ -184,7 +204,7 @@ else bazel_run_args+=("${post_config_bazel_args[@]}") fi set +e - "${bazel_cmd[@]}" \ + run_bazel "${bazel_cmd[@]:1}" \ --noexperimental_remote_repo_contents_cache \ "${bazel_run_args[@]}" \ -- \ diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index a09c7fd433..de9734f0e5 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -99,35 +99,27 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install nightly argument-comment-lint toolchain - if: ${{ runner.os == 'Windows' }} - shell: bash - run: | - rustup toolchain install nightly-2025-09-18 \ - --profile minimal \ - --component llvm-tools-preview \ - --component rustc-dev \ - --component rust-src \ - --no-self-update - rustup default nightly-2025-09-18 - name: Run argument comment lint on codex-rs via Bazel if: ${{ runner.os != 'Windows' }} env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash run: | - ./.github/scripts/run-bazel-ci.sh \ - -- \ - build \ + ./.github/scripts/run-argument-comment-lint-bazel.sh \ --config=argument-comment-lint \ --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ - -- \ - //codex-rs/... - - name: Run argument comment lint on codex-rs via packaged wrapper + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + - name: Run argument comment lint on codex-rs via Bazel if: ${{ runner.os == 'Windows' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash - run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py + run: | + ./.github/scripts/run-argument-comment-lint-bazel.sh \ + --config=argument-comment-lint \ + --platforms=//:local_windows \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} # --- CI to validate on different os/targets -------------------------------- lint_build: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a655b3071e..fde5075a39 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -159,35 +159,27 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install nightly argument-comment-lint toolchain - if: ${{ runner.os == 'Windows' }} - shell: bash - run: | - rustup toolchain install nightly-2025-09-18 \ - --profile minimal \ - --component llvm-tools-preview \ - --component rustc-dev \ - --component rust-src \ - --no-self-update - rustup default nightly-2025-09-18 - name: Run argument comment lint on codex-rs via Bazel if: ${{ runner.os != 'Windows' }} env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash run: | - ./.github/scripts/run-bazel-ci.sh \ - -- \ - build \ + ./.github/scripts/run-argument-comment-lint-bazel.sh \ --config=argument-comment-lint \ --keep_going \ - --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ - -- \ - //codex-rs/... - - name: Run argument comment lint on codex-rs via packaged wrapper + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + - name: Run argument comment lint on codex-rs via Bazel if: ${{ runner.os == 'Windows' }} + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} shell: bash - run: python3 ./tools/argument-comment-lint/run-prebuilt-linter.py + run: | + ./.github/scripts/run-argument-comment-lint-bazel.sh \ + --config=argument-comment-lint \ + --platforms=//:local_windows \ + --keep_going \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} # --- Gatherer job that you mark as the ONLY required status ----------------- results: diff --git a/BUILD.bazel b/BUILD.bazel index 0be4b711e6..3f59ff1160 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -17,12 +17,19 @@ platform( platform( name = "local_windows", constraint_values = [ - # We just need to pick one of the ABIs. Do the same one we target. "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm", ], parents = ["@platforms//host"], ) +platform( + name = "local_windows_msvc", + constraint_values = [ + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], + parents = ["@platforms//host"], +) + alias( name = "rbe", actual = "@rbe_platform", diff --git a/MODULE.bazel b/MODULE.bazel index e71f92ca77..3494f4f0a0 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -82,6 +82,11 @@ rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_r rules_rust.patch( patches = [ "//patches:rules_rust_windows_gnullvm_build_script.patch", + "//patches:rules_rust_windows_bootstrap_process_wrapper_linker.patch", + "//patches:rules_rust_windows_msvc_direct_link_args.patch", + "//patches:rules_rust_windows_exec_bin_target.patch", + "//patches:rules_rust_windows_exec_std.patch", + "//patches:rules_rust_repository_set_exec_constraints.patch", ], strip = 1, ) @@ -96,6 +101,34 @@ nightly_rust.toolchain( dev_components = True, edition = "2024", ) +# Keep Windows exec tools on MSVC so Bazel helper binaries link correctly, but +# lint crate targets as `windows-gnullvm` to preserve the repo's actual cfgs. +nightly_rust.repository_set( + name = "rust_windows_x86_64", + edition = "2024", + exec_triple = "x86_64-pc-windows-msvc", + exec_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], + target_triple = "x86_64-pc-windows-msvc", + versions = ["nightly/2025-09-18"], +) +nightly_rust.repository_set( + name = "rust_windows_x86_64", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm", + ], + target_triple = "x86_64-pc-windows-gnullvm", +) use_repo(nightly_rust, "rust_toolchains") toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 53c145b6d0..e1a08359b2 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -913,7 +913,6 @@ "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", - "hound_3.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cpal\",\"req\":\"^0.2.12\"}],\"features\":{}}", "http-body-util_0.1.3": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"sync\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"}],\"features\":{\"channel\":[\"dep:tokio\"],\"default\":[],\"full\":[\"channel\"]}}", "http-body_1.0.1": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"}],\"features\":{}}", "http-range-header_0.4.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.8.3\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 92e0c25923..3dd569c6b0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2637,7 +2637,6 @@ dependencies = [ "codex-arg0", "codex-chatgpt", "codex-cli", - "codex-client", "codex-cloud-requirements", "codex-core", "codex-features", @@ -2669,7 +2668,6 @@ dependencies = [ "diffy", "dirs", "dunce", - "hound", "image", "insta", "itertools 0.14.0", @@ -4819,12 +4817,6 @@ 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" @@ -8091,7 +8083,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 78113b126d..e36f495a1b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -503,9 +503,6 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, - "voice_transcription": { - "type": "boolean" - }, "web_search": { "type": "boolean" }, @@ -2148,9 +2145,6 @@ "use_linux_sandbox_bwrap": { "type": "boolean" }, - "voice_transcription": { - "type": "boolean" - }, "web_search": { "type": "boolean" }, diff --git a/codex-rs/core/src/shell_snapshot_tests.rs b/codex-rs/core/src/shell_snapshot_tests.rs index 90288300ff..ff700ff7a6 100644 --- a/codex-rs/core/src/shell_snapshot_tests.rs +++ b/codex-rs/core/src/shell_snapshot_tests.rs @@ -313,9 +313,15 @@ async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { shell_snapshot: crate::shell::empty_shell_snapshot_receiver(), }; - let err = run_script_with_timeout(&shell, &script, Duration::from_secs(1), true, dir.path()) - .await - .expect_err("snapshot shell should time out"); + let err = run_script_with_timeout( + &shell, + &script, + Duration::from_secs(1), + /*use_login_shell*/ true, + dir.path(), + ) + .await + .expect_err("snapshot shell should time out"); assert!( err.to_string().contains("timed out"), "expected timeout error, got {err:?}" diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 48927f4915..7f0d326a06 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -172,8 +172,6 @@ pub enum Feature { Artifact, /// Enable Fast mode selection in the TUI and request layer. FastMode, - /// Enable voice transcription in the TUI composer. - VoiceTranscription, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, /// Removed compatibility flag. The TUI now always uses the app-server implementation. @@ -813,12 +811,6 @@ 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::RealtimeConversation, key: "realtime_conversation", diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 307f956a4d..9a0e2c100c 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -274,7 +274,10 @@ mod tests { #[test] fn managed_network_enforces_seccomp_even_for_full_network_policy() { assert_eq!( - should_install_network_seccomp(NetworkSandboxPolicy::Enabled, true), + should_install_network_seccomp( + NetworkSandboxPolicy::Enabled, + /*allow_network_for_proxy*/ true, + ), true ); } @@ -282,7 +285,10 @@ mod tests { #[test] fn full_network_policy_without_managed_network_skips_seccomp() { assert_eq!( - should_install_network_seccomp(NetworkSandboxPolicy::Enabled, false), + should_install_network_seccomp( + NetworkSandboxPolicy::Enabled, + /*allow_network_for_proxy*/ false, + ), false ); } @@ -291,18 +297,22 @@ mod tests { fn restricted_network_policy_always_installs_seccomp() { assert!(should_install_network_seccomp( NetworkSandboxPolicy::Restricted, - false + /*allow_network_for_proxy*/ false, )); assert!(should_install_network_seccomp( NetworkSandboxPolicy::Restricted, - true + /*allow_network_for_proxy*/ true, )); } #[test] fn managed_proxy_routes_use_proxy_routed_seccomp_mode() { assert_eq!( - network_seccomp_mode(NetworkSandboxPolicy::Enabled, true, true), + network_seccomp_mode( + NetworkSandboxPolicy::Enabled, + /*allow_network_for_proxy*/ true, + /*proxy_routed_network*/ true, + ), Some(NetworkSeccompMode::ProxyRouted) ); } @@ -310,7 +320,11 @@ mod tests { #[test] fn restricted_network_without_proxy_routing_uses_restricted_mode() { assert_eq!( - network_seccomp_mode(NetworkSandboxPolicy::Restricted, false, false), + network_seccomp_mode( + NetworkSandboxPolicy::Restricted, + /*allow_network_for_proxy*/ false, + /*proxy_routed_network*/ false, + ), Some(NetworkSeccompMode::Restricted) ); } @@ -318,7 +332,11 @@ mod tests { #[test] fn full_network_without_managed_proxy_skips_network_seccomp_mode() { assert_eq!( - network_seccomp_mode(NetworkSandboxPolicy::Enabled, false, false), + network_seccomp_mode( + NetworkSandboxPolicy::Enabled, + /*allow_network_for_proxy*/ false, + /*proxy_routed_network*/ false, + ), None ); } diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index f57472c8a1..07e1893ee3 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -718,7 +718,8 @@ mod tests { #[test] fn rewrites_proxy_url_to_local_loopback_port() { let rewritten = - rewrite_proxy_env_value("socks5h://127.0.0.1:8081", 43210).expect("rewritten value"); + rewrite_proxy_env_value("socks5h://127.0.0.1:8081", /*local_port*/ 43210) + .expect("rewritten value"); assert_eq!(rewritten, "socks5h://127.0.0.1:43210"); } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index bc00682a41..33485008cb 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -23,7 +23,7 @@ default = ["voice-input"] vt100-tests = [] # Gate verbose debug logging inside the TUI implementation. debug-logs = [] -voice-input = ["dep:cpal", "dep:hound"] +voice-input = ["dep:cpal"] [lints] workspace = true @@ -38,7 +38,6 @@ codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } -codex-client = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-features = { workspace = true } @@ -80,7 +79,7 @@ ratatui = { workspace = true, features = [ ] } ratatui-macros = { workspace = true } regex-lite = { workspace = true } -reqwest = { workspace = true, features = ["json", "multipart"] } +reqwest = { workspace = true, features = ["json"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } @@ -118,7 +117,6 @@ 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 e402ea3239..3945f32f57 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5028,17 +5028,9 @@ 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); + let updated = self.chat_widget.update_recording_meter_in_place(&id, &text); if updated || self .chat_widget diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e652cb2351..2ae175013b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -489,22 +489,6 @@ pub(crate) enum AppEvent { text: String, }, - /// Voice transcription finished for the given placeholder id. - #[cfg(not(target_os = "linux"))] - #[cfg_attr(not(feature = "voice-input"), allow(dead_code))] - 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 d8b862e635..22da977e77 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -114,16 +114,6 @@ //! 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::key_hint; use crate::key_hint::KeyBinding; @@ -215,17 +205,8 @@ 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; @@ -296,34 +277,6 @@ 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, @@ -344,9 +297,6 @@ pub(crate) struct ChatComposer { /// `[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, @@ -474,8 +424,6 @@ impl ChatComposer { 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, @@ -603,22 +551,6 @@ impl ChatComposer { /// Compatibility shim for tests that still toggle the removed steer mode flag. #[cfg(test)] pub fn set_steer_enabled(&mut self, _enabled: bool) {} - 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 @@ -692,11 +624,6 @@ impl ChatComposer { 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) @@ -754,10 +681,6 @@ 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 { @@ -1002,9 +925,6 @@ 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(); @@ -1273,48 +1193,14 @@ 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(' ')) - { + if matches!(key_event.kind, KeyEventKind::Release) { 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), @@ -2648,136 +2534,6 @@ 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('!') } @@ -3515,11 +3271,6 @@ 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; @@ -3553,32 +3304,6 @@ impl ChatComposer { } } - #[cfg(not(target_os = "linux"))] - fn schedule_space_hold_timer(flag: Arc, frame: Option) { - const HOLD_DELAY_MILLIS: u64 = 1_000; - 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; @@ -3611,281 +3336,17 @@ 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 1s 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 { + pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { self.textarea.update_named_element_by_id(id, text) } - #[cfg(not(target_os = "linux"))] - pub fn insert_transcription_placeholder(&mut self, text: &str) -> String { + pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { let id = self.next_id(); self.textarea.insert_named_element(text, id.clone()); id } - pub fn remove_transcription_placeholder(&mut self, id: &str) { - self.stop_transcription_spinner(id); + pub fn remove_recording_meter_placeholder(&mut self, id: &str) { let _ = self.textarea.replace_element_by_id(id, ""); } } @@ -4257,15 +3718,6 @@ impl ChatComposer { } } -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::*; @@ -4377,6 +3829,26 @@ mod tests { ); } + #[cfg(not(target_os = "linux"))] + #[test] + fn remove_recording_meter_placeholder_clears_placeholder_text() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤"); + composer.remove_recording_meter_placeholder(&id); + + assert_eq!(composer.textarea.text(), ""); + assert!(composer.textarea.named_element_range(&id).is_none()); + } + #[test] fn footer_flash_expires_and_falls_back_to_hint_override() { let (tx, _rx) = unbounded_channel::(); @@ -6700,195 +6172,6 @@ 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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer.set_voice_transcription_enabled(/*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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer.set_voice_transcription_enabled(/*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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer.set_voice_transcription_enabled(/*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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ 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 slash_tab_completion_moves_cursor_to_end() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f06853798b..4016821d44 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -329,11 +329,6 @@ 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) { @@ -374,17 +369,6 @@ 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 { @@ -516,11 +500,7 @@ impl BottomPane { 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(); } @@ -1202,21 +1182,15 @@ impl BottomPane { #[cfg(not(target_os = "linux"))] impl BottomPane { - pub(crate) fn insert_transcription_placeholder(&mut self, text: &str) -> String { - let id = self.composer.insert_transcription_placeholder(text); + pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { + let id = self.composer.insert_recording_meter_placeholder(text); self.composer.sync_popups(); self.request_redraw(); id } - 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); + pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { + let updated = self.composer.update_recording_meter_in_place(id, text); if updated { self.composer.sync_popups(); self.request_redraw(); @@ -1224,8 +1198,8 @@ impl BottomPane { updated } - pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) { - self.composer.remove_transcription_placeholder(id); + pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) { + self.composer.remove_recording_meter_placeholder(id); self.composer.sync_popups(); self.request_redraw(); } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index f9e5f457ba..18a7f8d312 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -945,6 +945,7 @@ impl TextArea { self.set_cursor(end); } + #[cfg(not(target_os = "linux"))] pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool { if let Some(idx) = self .elements diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dd04aec372..ba05bdbb74 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4617,9 +4617,6 @@ impl ChatWidget { last_non_retry_error: None, }; - widget.bottom_pane.set_voice_transcription_enabled( - widget.config.features.enabled(Feature::VoiceTranscription), - ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -9167,9 +9164,6 @@ impl ChatWidget { ); } let enabled = self.config.features.enabled(feature); - if feature == Feature::VoiceTranscription { - self.bottom_pane.set_voice_transcription_enabled(enabled); - } if feature == Feature::RealtimeConversation { let realtime_conversation_enabled = self.realtime_conversation_enabled(); self.bottom_pane @@ -10657,22 +10651,16 @@ 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); + pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { + let updated = self.bottom_pane.update_recording_meter_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); + pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) { + self.bottom_pane.remove_recording_meter_placeholder(id); // Ensure the UI redraws to reflect placeholder removal. self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 6860a7ca05..1d350f0669 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -363,7 +363,7 @@ impl ChatWidget { return; } - let placeholder_id = self.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); + let placeholder_id = self.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤"); self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); self.request_redraw(); @@ -374,7 +374,7 @@ impl ChatWidget { Ok(capture) => capture, Err(err) => { self.realtime_conversation.meter_placeholder_id = None; - self.remove_transcription_placeholder(&placeholder_id); + self.remove_recording_meter_placeholder(&placeholder_id); self.fail_realtime_conversation(format!( "Failed to start microphone capture: {err}" )); @@ -464,10 +464,10 @@ impl ChatWidget { flag.store(true, Ordering::Relaxed); } if let Some(capture) = self.realtime_conversation.capture.take() { - let _ = capture.stop(); + capture.stop(); } if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() { - self.remove_transcription_placeholder(&id); + self.remove_recording_meter_placeholder(&id); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f54ff53745..a7119d962c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6087,7 +6087,7 @@ async fn realtime_error_closes_without_followup_closed_info() { async fn deleted_realtime_meter_uses_shared_stop_path() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.realtime_conversation.phase = RealtimeConversationPhase::Active; - let placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤"); + let placeholder_id = chat.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤"); chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone()); assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id)); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b84d39a03d..02b743fa8a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -155,21 +155,13 @@ mod version; 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 codex_core::config::Config; use codex_protocol::protocol::RealtimeAudioFrame; 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; @@ -177,34 +169,16 @@ mod voice { pub(crate) struct RealtimeAudioPlayer; impl VoiceCapture { - pub fn start() -> Result { - Err("voice input is unavailable in this build".to_string()) - } - pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> 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 stop(self) {} 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)) } @@ -231,18 +205,6 @@ mod voice { pub(crate) fn clear(&self) {} } - - 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; diff --git a/codex-rs/tui/src/voice.rs b/codex-rs/tui/src/voice.rs index df8c31953e..cd135870f8 100644 --- a/codex-rs/tui/src/voice.rs +++ b/codex-rs/tui/src/voice.rs @@ -1,89 +1,33 @@ -use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use base64::Engine; -use codex_client::build_reqwest_client_with_custom_ca; -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 codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; 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; -const AUDIO_MODEL: &str = "gpt-4o-mini-transcribe"; const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000; const MODEL_AUDIO_CHANNELS: u16 = 1; -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_default_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 start_realtime(config: &Config, tx: AppEventSender) -> Result { let (device, config) = select_realtime_input_device_and_config(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)); @@ -101,47 +45,22 @@ impl VoiceCapture { Ok(Self { stream: Some(stream), - sample_rate, - channels, - data, stopped, last_peak, }) } - pub fn stop(mut self) -> Result { + pub fn stop(mut self) { // 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() } @@ -206,136 +125,16 @@ impl RecordingMeterState { } } -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_default_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 = crate::audio_device::preferred_input_config(&device)?; - Ok((device, config)) -} - fn select_realtime_input_device_and_config( config: &Config, ) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> { crate::audio_device::select_configured_input_device_and_config(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()), - } -} - fn build_realtime_input_stream( device: &cpal::Device, config: &cpal::SupportedStreamConfig, @@ -673,210 +472,10 @@ fn convert_pcm16( out } -// ------------------------- -// 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 converted; - let (channels, sample_rate, segment) = - if audio.channels == MODEL_AUDIO_CHANNELS && audio.sample_rate == MODEL_AUDIO_SAMPLE_RATE { - (audio.channels, audio.sample_rate, audio.data.as_slice()) - } else { - converted = convert_pcm16( - &audio.data, - audio.sample_rate, - audio.channels, - MODEL_AUDIO_SAMPLE_RATE, - MODEL_AUDIO_CHANNELS, - ); - ( - MODEL_AUDIO_CHANNELS, - MODEL_AUDIO_SAMPLE_RATE, - converted.as_slice(), - ) - }; - - let mut wav_bytes: Vec = Vec::new(); - let spec = WavSpec { - channels, - 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 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 = build_reqwest_client_with_custom_ca(reqwest::Client::builder()) - .map_err(|error| format!("failed to build transcription HTTP client: {error}"))?; - 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", AUDIO_MODEL) - .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) - } -} - #[cfg(test)] mod tests { - use super::RecordedAudio; use super::convert_pcm16; - use super::encode_wav_normalized; use pretty_assertions::assert_eq; - use std::io::Cursor; #[test] fn convert_pcm16_downmixes_and_resamples_for_model_input() { @@ -887,25 +486,4 @@ mod tests { ); assert_eq!(converted, vec![200, 700]); } - - #[test] - fn encode_wav_normalized_outputs_24khz_mono_audio() { - let audio = RecordedAudio { - data: vec![100, 300, 200, 400, 500, 700, 600, 800], - sample_rate: 48_000, - channels: 2, - }; - - let wav = encode_wav_normalized(&audio).expect("wav should encode"); - let reader = hound::WavReader::new(Cursor::new(wav)).expect("wav should parse"); - let spec = reader.spec(); - let samples = reader - .into_samples::() - .collect::, _>>() - .expect("samples should decode"); - - assert_eq!(spec.channels, 1); - assert_eq!(spec.sample_rate, 24_000); - assert_eq!(samples, vec![8_426, 29_490]); - } } diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index 8d4acbbb91..56c857e31f 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -2,6 +2,11 @@ exports_files([ "abseil_windows_gnullvm_thread_identity.patch", "aws-lc-sys_memcmp_check.patch", "llvm_windows_symlink_extract.patch", + "rules_rust_windows_bootstrap_process_wrapper_linker.patch", + "rules_rust_windows_exec_bin_target.patch", + "rules_rust_windows_exec_std.patch", + "rules_rust_repository_set_exec_constraints.patch", + "rules_rust_windows_msvc_direct_link_args.patch", "rules_rust_windows_gnullvm_build_script.patch", "rules_rs_windows_gnullvm_exec.patch", "rusty_v8_prebuilt_out_dir.patch", diff --git a/patches/rules_rust_repository_set_exec_constraints.patch b/patches/rules_rust_repository_set_exec_constraints.patch new file mode 100644 index 0000000000..31afae4f7a --- /dev/null +++ b/patches/rules_rust_repository_set_exec_constraints.patch @@ -0,0 +1,26 @@ +# What: let `rules_rust` repository_set entries specify an explicit exec-platform +# constraint set. +# Why: codex needs Windows nightly lint toolchains to run helper binaries on an +# MSVC exec platform while still targeting `windows-gnullvm` crates. + +diff --git a/rust/extensions.bzl b/rust/extensions.bzl +--- a/rust/extensions.bzl ++++ b/rust/extensions.bzl +@@ -52,6 +52,7 @@ def _rust_impl(module_ctx): + "allocator_library": repository_set.allocator_library, + "dev_components": repository_set.dev_components, + "edition": repository_set.edition, ++ "exec_compatible_with": [str(v) for v in repository_set.exec_compatible_with] if repository_set.exec_compatible_with else None, + "exec_triple": repository_set.exec_triple, + "extra_target_triples": {repository_set.target_triple: [str(v) for v in repository_set.target_compatible_with]}, + "name": repository_set.name, +@@ -166,6 +167,9 @@ _COMMON_TAG_KWARGS = { + + _RUST_REPOSITORY_SET_TAG_ATTRS = { ++ "exec_compatible_with": attr.label_list( ++ doc = "Execution platform constraints for this repository_set.", ++ ), + "exec_triple": attr.string( + doc = "Exec triple for this repository_set.", + ), + "name": attr.string( diff --git a/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch b/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch new file mode 100644 index 0000000000..9a978b8be6 --- /dev/null +++ b/patches/rules_rust_windows_bootstrap_process_wrapper_linker.patch @@ -0,0 +1,23 @@ +--- a/rust/private/rustc.bzl ++++ b/rust/private/rustc.bzl +@@ -472,7 +472,19 @@ + ) + ld_is_direct_driver = False + +- if not ld or toolchain.linker_preference == "rust": ++ # The bootstrap process wrapper is built without the normal rules_rust ++ # process wrapper. On Windows nightly toolchains that expose rust-lld, the ++ # C++ toolchain path currently resolves to clang++ while still emitting ++ # MSVC-style arguments, so prefer rust-lld for this one bootstrap binary ++ # instead of switching all Rust actions over. ++ use_bootstrap_rust_linker = ( ++ toolchain.target_os.startswith("windows") and ++ toolchain.linker != None and ++ hasattr(ctx.executable, "_bootstrap_process_wrapper") and ++ not ctx.executable._process_wrapper ++ ) ++ ++ if not ld or toolchain.linker_preference == "rust" or use_bootstrap_rust_linker: + ld = toolchain.linker.path + ld_is_direct_driver = toolchain.linker_type == "direct" + diff --git a/patches/rules_rust_windows_exec_bin_target.patch b/patches/rules_rust_windows_exec_bin_target.patch new file mode 100644 index 0000000000..e4cf306dff --- /dev/null +++ b/patches/rules_rust_windows_exec_bin_target.patch @@ -0,0 +1,71 @@ +# What: compile exec-side Rust binaries against the exec Windows triple instead +# of the lint target triple. +# Why: Windows native argument-comment-lint keeps the repo target platform on +# `windows-gnullvm` to preserve cfg coverage, but exec-side helper binaries +# (build.rs, runners, bootstrap tools) must link as host tools. With +# `toolchain_linker_preference=rust`, rules_rust was still feeding those exec +# binaries the `windows-gnullvm` target/std path, which broke linking under the +# native Bazel lint lane. + +diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl +--- a/rust/private/rustc.bzl ++++ b/rust/private/rustc.bzl +@@ -129,6 +129,20 @@ + build_setting = config.bool(flag = True), + ) + +-def _get_rustc_env(attr, toolchain, crate_name): ++def _effective_target_arch(toolchain, use_exec_target): ++ return toolchain.exec_triple.arch if use_exec_target else toolchain.target_arch ++ ++def _effective_target_os(toolchain, use_exec_target): ++ return toolchain.exec_triple.system if use_exec_target else toolchain.target_os ++ ++def _effective_target_flag_value(toolchain, use_exec_target): ++ return toolchain.exec_triple.str if use_exec_target else toolchain.target_flag_value ++ ++def _effective_rust_std_paths(toolchain, use_exec_target): ++ if use_exec_target: ++ return ["{}/lib/rustlib/{}/lib".format(toolchain.sysroot, toolchain.exec_triple.str)] ++ return toolchain.rust_std_paths ++ ++def _get_rustc_env(attr, toolchain, crate_name, use_exec_target = False): + """Gathers rustc environment variables + +@@ -147,6 +161,6 @@ + + result = { +- "CARGO_CFG_TARGET_ARCH": "" if toolchain.target_arch == None else toolchain.target_arch, +- "CARGO_CFG_TARGET_OS": "" if toolchain.target_os == None else toolchain.target_os, ++ "CARGO_CFG_TARGET_ARCH": "" if _effective_target_arch(toolchain, use_exec_target) == None else _effective_target_arch(toolchain, use_exec_target), ++ "CARGO_CFG_TARGET_OS": "" if _effective_target_os(toolchain, use_exec_target) == None else _effective_target_os(toolchain, use_exec_target), + "CARGO_CRATE_NAME": crate_name, + "CARGO_PKG_AUTHORS": "", +@@ -997,9 +1011,11 @@ + if build_metadata and not use_json_output: + fail("build_metadata requires parse_json_output") + ++ use_exec_target = is_exec_configuration(ctx) and crate_info.type == "bin" ++ + output_dir = getattr(crate_info.output, "dirname", None) + linker_script = getattr(file, "linker_script", None) + +- env = _get_rustc_env(attr, toolchain, crate_info.name) ++ env = _get_rustc_env(attr, toolchain, crate_info.name, use_exec_target) + + # Wrapper args first +@@ -1138,5 +1154,5 @@ + if error_format != "json": + # Color is not compatible with json output. + rustc_flags.add("--color=always") +- rustc_flags.add(toolchain.target_flag_value, format = "--target=%s") ++ rustc_flags.add(_effective_target_flag_value(toolchain, use_exec_target), format = "--target=%s") + if hasattr(attr, "crate_features"): +@@ -1144,6 +1160,6 @@ + if linker_script: + rustc_flags.add(linker_script, format = "--codegen=link-arg=-T%s") + + # Tell Rustc where to find the standard library (or libcore) +- rustc_flags.add_all(toolchain.rust_std_paths, before_each = "-L", format_each = "%s") ++ rustc_flags.add_all(_effective_rust_std_paths(toolchain, use_exec_target), before_each = "-L", format_each = "%s") + rustc_flags.add_all(rust_flags, map_each = map_flag) diff --git a/patches/rules_rust_windows_exec_std.patch b/patches/rules_rust_windows_exec_std.patch new file mode 100644 index 0000000000..78c15f90a2 --- /dev/null +++ b/patches/rules_rust_windows_exec_std.patch @@ -0,0 +1,164 @@ +# What: expose an exec-side Rust standard library alongside the target stdlib. +# Why: mixed Windows toolchains compile repo crates for `windows-gnullvm`, but +# exec-side helper binaries (build.rs, runners) may need the host MSVC stdlib. +# The toolchain sysroot must therefore carry both stdlib trees so rustc can +# resolve the correct one for each `--target`. + +diff --git a/rust/toolchain.bzl b/rust/toolchain.bzl +--- a/rust/toolchain.bzl ++++ b/rust/toolchain.bzl +@@ -209,6 +209,7 @@ def _generate_sysroot( + clippy = None, + cargo_clippy = None, + llvm_tools = None, ++ exec_rust_std = None, + rust_std = None, + rustfmt = None, + linker = None): +@@ -312,7 +313,15 @@ def _generate_sysroot( + + # Made available to support $(location) expansion in stdlib_linkflags and extra_rustc_flags. + transitive_file_sets.append(depset(ctx.files.rust_std)) ++ ++ sysroot_exec_rust_std = None ++ if exec_rust_std: ++ sysroot_exec_rust_std = _symlink_sysroot_tree(ctx, name, exec_rust_std) ++ transitive_file_sets.extend([sysroot_exec_rust_std]) + ++ # Made available to support $(location) expansion in extra_exec_rustc_flags. ++ transitive_file_sets.append(depset(ctx.files.exec_rust_std)) ++ + # Declare a file in the root of the sysroot to make locating the sysroot easy + sysroot_anchor = ctx.actions.declare_file("{}/rust.sysroot".format(name)) + ctx.actions.write( +@@ -323,6 +332,7 @@ def _generate_sysroot( + "cargo-clippy: {}".format(cargo_clippy), + "linker: {}".format(linker), + "llvm_tools: {}".format(llvm_tools), ++ "exec_rust_std: {}".format(exec_rust_std), + "rust_std: {}".format(rust_std), + "rustc_lib: {}".format(rustc_lib), + "rustc: {}".format(rustc), +@@ -340,6 +350,7 @@ def _generate_sysroot( + cargo_clippy = sysroot_cargo_clippy, + clippy = sysroot_clippy, + linker = sysroot_linker, ++ exec_rust_std = sysroot_exec_rust_std, + rust_std = sysroot_rust_std, + rustc = sysroot_rustc, + rustc_lib = sysroot_rustc_lib, +@@ -410,12 +421,14 @@ def _rust_toolchain_impl(ctx): + ) + + rust_std = ctx.attr.rust_std ++ exec_rust_std = ctx.attr.exec_rust_std if ctx.attr.exec_rust_std else rust_std + + sysroot = _generate_sysroot( + ctx = ctx, + rustc = ctx.file.rustc, + rustdoc = ctx.file.rust_doc, + rustc_lib = ctx.attr.rustc_lib, ++ exec_rust_std = exec_rust_std, + rust_std = rust_std, + rustfmt = ctx.file.rustfmt, + clippy = ctx.file.clippy_driver, +@@ -452,7 +465,7 @@ def _rust_toolchain_impl(ctx): + + expanded_stdlib_linkflags = _expand_flags(ctx, "stdlib_linkflags", rust_std[rust_common.stdlib_info].srcs, make_variables) + expanded_extra_rustc_flags = _expand_flags(ctx, "extra_rustc_flags", rust_std[rust_common.stdlib_info].srcs, make_variables) +- expanded_extra_exec_rustc_flags = _expand_flags(ctx, "extra_exec_rustc_flags", rust_std[rust_common.stdlib_info].srcs, make_variables) ++ expanded_extra_exec_rustc_flags = _expand_flags(ctx, "extra_exec_rustc_flags", exec_rust_std[rust_common.stdlib_info].srcs, make_variables) + + linking_context = cc_common.create_linking_context( + linker_inputs = depset([ +@@ -793,6 +806,10 @@ rust_toolchain = rule( + doc = "The Rust standard library.", + mandatory = True, + ), ++ "exec_rust_std": attr.label( ++ doc = "Optional Rust standard library for exec-configuration Rust tools. Defaults to rust_std.", ++ mandatory = False, ++ ), + "rustc": attr.label( + doc = "The location of the `rustc` binary. Can be a direct source or a filegroup containing one item.", + allow_single_file = True, +diff --git a/rust/private/repository_utils.bzl b/rust/private/repository_utils.bzl +--- a/rust/private/repository_utils.bzl ++++ b/rust/private/repository_utils.bzl +@@ -341,6 +341,7 @@ rust_toolchain( + name = "{toolchain_name}", + rust_doc = "//:rustdoc", + rust_std = "//:rust_std-{target_triple}", ++ exec_rust_std = {exec_rust_std_label}, + rustc = "//:rustc", + linker = {linker_label}, + linker_type = {linker_type}, +@@ -384,6 +385,7 @@ def BUILD_for_rust_toolchain( + include_llvm_tools, + include_linker, + include_objcopy = False, ++ exec_rust_std_label = None, + stdlib_linkflags = None, + extra_rustc_flags = None, + extra_exec_rustc_flags = None, +@@ -405,6 +407,7 @@ def BUILD_for_rust_toolchain( + include_llvm_tools (bool): Whether llvm-tools are present in the toolchain. + include_linker (bool): Whether a linker is available in the toolchain. + include_objcopy (bool): Whether rust-objcopy is available in the toolchain. ++ exec_rust_std_label (str, optional): Label for an exec-side stdlib when it differs from rust_std. + stdlib_linkflags (list, optional): Overridden flags needed for linking to rust + stdlib, akin to BAZEL_LINKLIBS. Defaults to + None. +@@ -453,6 +456,7 @@ def BUILD_for_rust_toolchain( + staticlib_ext = system_to_staticlib_ext(target_triple.system), + dylib_ext = system_to_dylib_ext(target_triple.system), + allocator_library = repr(allocator_library_label), ++ exec_rust_std_label = repr(exec_rust_std_label), + global_allocator_library = repr(global_allocator_library_label), + stdlib_linkflags = stdlib_linkflags, + default_edition = default_edition, +diff --git a/rust/private/rustc.bzl b/rust/private/rustc.bzl +--- a/rust/private/rustc.bzl ++++ b/rust/private/rustc.bzl +@@ -1011,7 +1011,10 @@ def construct_arguments( + if build_metadata and not use_json_output: + fail("build_metadata requires parse_json_output") + +- use_exec_target = is_exec_configuration(ctx) and crate_info.type == "bin" ++ # Exec-configuration crates (build scripts, proc-macros, and their ++ # dependencies) must all target the exec triple so they can link against ++ # each other and the exec-side standard library. ++ use_exec_target = is_exec_configuration(ctx) + + output_dir = getattr(crate_info.output, "dirname", None) + linker_script = getattr(file, "linker_script", None) +diff --git a/rust/repositories.bzl b/rust/repositories.bzl +--- a/rust/repositories.bzl ++++ b/rust/repositories.bzl +@@ -536,6 +536,18 @@ def _rust_toolchain_tools_repository_impl(ctx): + build_components.append(rust_stdlib_content) + sha256s.update(rust_stdlib_sha256) + ++ exec_rust_std_label = None ++ if exec_triple.str != target_triple.str: ++ exec_rust_stdlib_content, exec_rust_stdlib_sha256 = load_rust_stdlib( ++ ctx = ctx, ++ target_triple = exec_triple, ++ version = version, ++ iso_date = iso_date, ++ ) ++ build_components.append(exec_rust_stdlib_content) ++ sha256s.update(exec_rust_stdlib_sha256) ++ exec_rust_std_label = "//:rust_std-{}".format(exec_triple.str) ++ + stdlib_linkflags = None + if "BAZEL_RUST_STDLIB_LINKFLAGS" in ctx.os.environ: + stdlib_linkflags = ctx.os.environ["BAZEL_RUST_STDLIB_LINKFLAGS"].split(":") +@@ -552,6 +564,7 @@ def _rust_toolchain_tools_repository_impl(ctx): + include_llvm_tools = include_llvm_tools, + include_linker = include_linker, + include_objcopy = include_objcopy, ++ exec_rust_std_label = exec_rust_std_label, + extra_rustc_flags = ctx.attr.extra_rustc_flags, + extra_exec_rustc_flags = ctx.attr.extra_exec_rustc_flags, + opt_level = ctx.attr.opt_level if ctx.attr.opt_level else None, diff --git a/patches/rules_rust_windows_msvc_direct_link_args.patch b/patches/rules_rust_windows_msvc_direct_link_args.patch new file mode 100644 index 0000000000..2f04f8ca89 --- /dev/null +++ b/patches/rules_rust_windows_msvc_direct_link_args.patch @@ -0,0 +1,64 @@ +--- a/rust/private/rustc.bzl ++++ b/rust/private/rustc.bzl +@@ -2305,7 +2305,7 @@ + return crate.metadata.dirname + return crate.output.dirname + +-def _portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = False, for_darwin = False, flavor_msvc = False): ++def _portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, for_windows = False, for_darwin = False, flavor_msvc = False, use_direct_driver = False): + artifact = get_preferred_artifact(lib, use_pic) + if ambiguous_libs and artifact.path in ambiguous_libs: + artifact = ambiguous_libs[artifact.path] +@@ -2344,6 +2344,11 @@ + artifact.basename.startswith("test-") or artifact.basename.startswith("std-") + ): + return [] if for_darwin else ["-lstatic=%s" % get_lib_name(artifact)] ++ ++ if for_windows and use_direct_driver and not artifact.basename.endswith(".lib"): ++ return [ ++ "-Clink-arg={}".format(artifact.path), ++ ] + + if flavor_msvc: + return [ +@@ -2381,7 +2386,7 @@ + ]) + elif include_link_flags: + get_lib_name = get_lib_name_for_windows if flavor_msvc else get_lib_name_default +- ret.extend(_portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc)) ++ ret.extend(_portable_link_flags(lib, use_pic, ambiguous_libs, get_lib_name, flavor_msvc = flavor_msvc, use_direct_driver = use_direct_driver)) + + # Windows toolchains can inherit POSIX defaults like -pthread from C deps, + # which fails to link with the MinGW/LLD toolchain. Drop them here. +@@ -2558,17 +2563,25 @@ + else: + # For all other crate types we want to link C++ runtime library statically + # (for example libstdc++.a or libc++.a). ++ runtime_libs = cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration) + args.add_all( +- cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration), ++ runtime_libs, + map_each = _get_dirname, + format_each = "-Lnative=%s", + ) + if include_link_flags: +- args.add_all( +- cc_toolchain.static_runtime_lib(feature_configuration = feature_configuration), +- map_each = get_lib_name, +- format_each = "-lstatic=%s", +- ) ++ if toolchain.target_os == "windows" and use_direct_link_driver: ++ for runtime_lib in runtime_libs.to_list(): ++ if runtime_lib.basename.endswith(".lib"): ++ args.add(get_lib_name(runtime_lib), format = "-lstatic=%s") ++ else: ++ args.add(runtime_lib.path, format = "--codegen=link-arg=%s") ++ else: ++ args.add_all( ++ runtime_libs, ++ map_each = get_lib_name, ++ format_each = "-lstatic=%s", ++ ) + + def _get_dirname(file): + """A helper function for `_add_native_link_flags`.