Merge e922224c58 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-03-28 19:32:33 -07:00
committed by GitHub
31 changed files with 637 additions and 1363 deletions
+8 -1
View File
@@ -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
View File
@@ -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[@]}"
+23 -3
View File
@@ -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[@]}" \
-- \
+11 -19
View File
@@ -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:
+11 -19
View File
@@ -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
View File
@@ -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",
+33
View File
@@ -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")
-1
View File
@@ -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\":{}}",
-9
View File
@@ -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",
-6
View File
@@ -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"
},
+9 -3
View File
@@ -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:?}"
-8
View File
@@ -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",
+25 -7
View File
@@ -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
);
}
+2 -1
View File
@@ -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");
}
+2 -4
View File
@@ -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 }
+1 -9
View File
@@ -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
-16
View File
@@ -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),
+24 -741
View File
@@ -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;
+6 -32
View File
@@ -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();
}
+1
View File
@@ -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
+4 -16
View File
@@ -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();
}
+4 -4
View File
@@ -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);
}
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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]);
}
}
+5
View File
@@ -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)
+164
View File
@@ -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`.