mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Merge e922224c58 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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'
|
||||
|
||||
+109
@@ -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[@]}"
|
||||
@@ -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[@]}" \
|
||||
-- \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+8
-1
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
Generated
-1
@@ -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\":{}}",
|
||||
|
||||
Generated
-9
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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<Instant>,
|
||||
space_hold_element_id: Option<String>,
|
||||
space_hold_trigger: Option<Arc<AtomicBool>>,
|
||||
key_release_supported: bool,
|
||||
space_hold_repeat_seen: bool,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
voice: Option<crate::voice::VoiceCapture>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
recording_placeholder_id: Option<String>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
space_recording_started_at: Option<Instant>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
space_recording_last_repeat_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl VoiceState {
|
||||
fn new(key_release_supported: bool) -> Self {
|
||||
Self {
|
||||
key_release_supported,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ChatComposer {
|
||||
textarea: TextArea,
|
||||
textarea_state: RefCell<TextAreaState>,
|
||||
@@ -344,9 +297,6 @@ pub(crate) struct ChatComposer {
|
||||
/// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images.
|
||||
attached_images: Vec<AttachedImage>,
|
||||
placeholder_text: String,
|
||||
voice_state: VoiceState,
|
||||
// Spinner control flags keyed by placeholder id; set to true to stop.
|
||||
spinner_stop_flags: HashMap<String, Arc<AtomicBool>>,
|
||||
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<PathBuf>,
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
) {
|
||||
#[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<String>) {
|
||||
self.input_enabled = enabled;
|
||||
@@ -3553,32 +3304,6 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
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<AtomicBool>, frame: Option<FrameRequester>) {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
if let Some(frame) = frame {
|
||||
frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_status_line(&mut self, status_line: Option<Line<'static>>) -> 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<Mutex<Vec<i16>>>,
|
||||
last_peak: Arc<std::sync::atomic::AtomicU16>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
@@ -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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
+1
-39
@@ -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<i16>,
|
||||
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<Self, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result<Self, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn stop(self) -> Result<RecordedAudio, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn data_arc(&self) -> Arc<Mutex<Vec<i16>>> {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
pub fn stop(self) {}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
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<AtomicU16> {
|
||||
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<String>,
|
||||
tx: AppEventSender,
|
||||
) {
|
||||
tx.send(AppEvent::TranscriptionFailed {
|
||||
id,
|
||||
error: "voice input is unavailable in this build".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mod wrapping;
|
||||
|
||||
+1
-423
@@ -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<String>,
|
||||
chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
pub struct RecordedAudio {
|
||||
pub data: Vec<i16>,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
}
|
||||
|
||||
pub struct VoiceCapture {
|
||||
stream: Option<cpal::Stream>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
data: Arc<Mutex<Vec<i16>>>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl VoiceCapture {
|
||||
pub fn start() -> Result<Self, String> {
|
||||
let (device, config) = select_default_input_device_and_config()?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let data: Arc<Mutex<Vec<i16>>> = 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<Self, String> {
|
||||
let (device, config) = select_realtime_input_device_and_config(config)?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let data: Arc<Mutex<Vec<i16>>> = 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<RecordedAudio, String> {
|
||||
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<Mutex<Vec<i16>>> {
|
||||
self.data.clone()
|
||||
}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
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<AtomicU16> {
|
||||
self.last_peak.clone()
|
||||
}
|
||||
@@ -206,136 +125,16 @@ impl RecordingMeterState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transcribe_async(
|
||||
id: String,
|
||||
audio: RecordedAudio,
|
||||
context: Option<String>,
|
||||
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<String, String> = 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<Mutex<Vec<i16>>>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
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<Vec<u8>, 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<u8> = 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<TranscriptionAuthContext, String> {
|
||||
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<u8>,
|
||||
context: Option<String>,
|
||||
duration_seconds: f32,
|
||||
) -> Result<String, String> {
|
||||
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(|_| "<failed to read body>".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::<i16>()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.expect("samples should decode");
|
||||
|
||||
assert_eq!(spec.channels, 1);
|
||||
assert_eq!(spec.sample_rate, 24_000);
|
||||
assert_eq!(samples, vec![8_426, 29_490]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user