Compare commits

..

1 Commits

Author SHA1 Message Date
jif-oai
bb71bc2d62 Release 0.105.0-alpha.16 2026-02-23 20:15:11 +00:00
29 changed files with 579 additions and 3495 deletions

View File

@@ -1,11 +1,4 @@
load("@apple_support//xcode:xcode_config.bzl", "xcode_config")
load("@rules_cc//cc:defs.bzl", "cc_shared_library")
cc_shared_library(
name = "clang",
deps = ["@llvm-project//clang:libclang"],
visibility = ["//visibility:public"],
)
xcode_config(name = "disable_xcode")

View File

@@ -1,7 +1,5 @@
module(name = "codex")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.6")
bazel_dep(name = "toolchains_llvm_bootstrapped", version = "0.5.3")
single_version_override(
module_name = "toolchains_llvm_bootstrapped",
patch_strip = 1,
@@ -10,8 +8,6 @@ single_version_override(
],
)
register_toolchains("@toolchains_llvm_bootstrapped//toolchain:all")
osx = use_extension("@toolchains_llvm_bootstrapped//extensions:osx.bzl", "osx")
osx.framework(name = "ApplicationServices")
osx.framework(name = "AppKit")
@@ -20,12 +16,8 @@ osx.framework(name = "CoreFoundation")
osx.framework(name = "CoreGraphics")
osx.framework(name = "CoreServices")
osx.framework(name = "CoreText")
osx.framework(name = "AudioToolbox")
osx.framework(name = "CFNetwork")
osx.framework(name = "FontServices")
osx.framework(name = "AudioUnit")
osx.framework(name = "CoreAudio")
osx.framework(name = "CoreAudioTypes")
osx.framework(name = "Foundation")
osx.framework(name = "ImageIO")
osx.framework(name = "IOKit")
@@ -33,7 +25,10 @@ osx.framework(name = "Kernel")
osx.framework(name = "OSLog")
osx.framework(name = "Security")
osx.framework(name = "SystemConfiguration")
use_repo(osx, "macosx15.4.sdk")
register_toolchains(
"@toolchains_llvm_bootstrapped//toolchain:all",
)
# Needed to disable xcode...
bazel_dep(name = "apple_support", version = "2.1.0")
@@ -44,9 +39,9 @@ bazel_dep(name = "rules_rs", version = "0.0.23")
# Special toolchains branch
archive_override(
module_name = "rules_rs",
integrity = "sha256-O34UF4H7b1Qacu3vlu2Od4ILGVApzg5j1zl952SFL3w=",
strip_prefix = "rules_rs-097123c2aa72672e371e69e7035869f5a45c7b2b",
url = "https://github.com/dzbarsky/rules_rs/archive/097123c2aa72672e371e69e7035869f5a45c7b2b.tar.gz",
integrity = "sha256-YbDRjZos4UmfIPY98znK1BgBWRQ1/ui3CtL6RqxE30I=",
strip_prefix = "rules_rs-6cf3d940fdc48baf3ebd6c37daf8e0be8fc73ecb",
url = "https://github.com/dzbarsky/rules_rs/archive/6cf3d940fdc48baf3ebd6c37daf8e0be8fc73ecb.tar.gz",
)
rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust")
@@ -139,9 +134,6 @@ crate.annotation(
"OPENSSL_NO_VENDOR": "1",
"OPENSSL_STATIC": "1",
},
crate_features = [
"dep:openssl-src",
],
crate = "openssl-sys",
data = ["@openssl//:gen_dir"],
)
@@ -153,28 +145,6 @@ crate.annotation(
workspace_cargo_toml = "rust/runfiles/Cargo.toml",
)
llvm = use_extension("@toolchains_llvm_bootstrapped//extensions:llvm.bzl", "llvm")
use_repo(llvm, "llvm-project")
crate.annotation(
# Provide the hermetic SDK path so the build script doesn't try to invoke an unhermetic `xcrun --show-sdk-path`.
build_script_data = [
"@macosx15.4.sdk//sysroot",
],
build_script_env = {
"BINDGEN_EXTRA_CLANG_ARGS": "-isystem $(location @toolchains_llvm_bootstrapped//:builtin_headers)",
"COREAUDIO_SDK_PATH": "$(location @macosx15.4.sdk//sysroot)",
"LIBCLANG_PATH": "$(location @codex//:clang)",
},
build_script_tools = [
"@codex//:clang",
"@toolchains_llvm_bootstrapped//:builtin_headers",
],
crate = "coreaudio-sys",
)
inject_repo(crate, "codex", "toolchains_llvm_bootstrapped", "macosx15.4.sdk")
# Fix readme inclusions
crate.annotation(
crate = "windows-link",
@@ -205,17 +175,6 @@ crate.annotation(
gen_build_script = "off",
deps = [":windows_import_lib"],
)
bazel_dep(name = "alsa_lib", version = "1.2.9.bcr.4")
crate.annotation(
crate = "alsa-sys",
gen_build_script = "off",
deps = ["@alsa_lib"],
)
inject_repo(crate, "alsa_lib")
use_repo(crate, "crates")
rbe_platform_repository = use_repo_rule("//:rbe.bzl", "rbe_platform_repository")

282
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

926
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.0.0"
version = "0.105.0-alpha.16"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024
@@ -206,7 +206,7 @@ opentelemetry-otlp = "0.31.0"
opentelemetry-semantic-conventions = "0.31.0"
opentelemetry_sdk = "0.31.0"
os_info = "3.12.0"
owo-colors = "4.3.0"
owo-colors = "4.2.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
portable-pty = "0.9.0"

View File

@@ -415,9 +415,6 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"voice_transcription": {
"type": "boolean"
},
"web_search": {
"type": "boolean"
},
@@ -1699,9 +1696,6 @@
"use_linux_sandbox_bwrap": {
"type": "boolean"
},
"voice_transcription": {
"type": "boolean"
},
"web_search": {
"type": "boolean"
},

View File

@@ -138,8 +138,6 @@ pub enum Feature {
CollaborationModes,
/// Enable personality selection in the TUI.
Personality,
/// Enable voice transcription in the TUI composer.
VoiceTranscription,
/// Prevent idle system sleep while a turn is actively running.
PreventIdleSleep,
/// Use the Responses API WebSocket transport for OpenAI by default.
@@ -629,12 +627,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::PreventIdleSleep,
key: "prevent_idle_sleep",

View File

@@ -217,8 +217,7 @@ pub(crate) async fn handle_start(
_ => None,
};
if let Some(text) = maybe_routed_text {
let sess_for_routed_text = Arc::clone(&sess_clone);
sess_for_routed_text.route_realtime_text_input(text).await;
sess_clone.route_realtime_text_input(text).await;
}
sess_clone
.send_event_raw(ev(EventMsg::RealtimeConversationRealtime(
@@ -253,25 +252,16 @@ pub(crate) async fn handle_audio(
}
fn realtime_text_from_conversation_item(item: &Value) -> Option<String> {
match item.get("type").and_then(Value::as_str) {
Some("message") => {
if item.get("role").and_then(Value::as_str) != Some("assistant") {
return None;
}
let content = item.get("content")?.as_array()?;
let text = content
.iter()
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|entry| entry.get("text").and_then(Value::as_str))
.collect::<String>();
if text.is_empty() { None } else { Some(text) }
}
Some("spawn_transcript") => item
.get("delta_user_transcript")
.and_then(Value::as_str)
.and_then(|text| (!text.is_empty()).then(|| text.to_string())),
Some(_) | None => None,
if item.get("type").and_then(Value::as_str) != Some("message") {
return None;
}
let content = item.get("content")?.as_array()?;
let text = content
.iter()
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|entry| entry.get("text").and_then(Value::as_str))
.collect::<String>();
if text.is_empty() { None } else { Some(text) }
}
pub(crate) async fn handle_text(
@@ -311,6 +301,7 @@ fn spawn_realtime_input_task(
tokio::spawn(async move {
loop {
tokio::select! {
biased;
text = text_rx.recv() => {
match text {
Ok(text) => {
@@ -397,7 +388,7 @@ mod tests {
use serde_json::json;
#[test]
fn extracts_text_from_assistant_message_items_only() {
fn extracts_text_from_message_items_ignoring_role() {
let assistant = json!({
"type": "message",
"role": "assistant",
@@ -413,14 +404,16 @@ mod tests {
"role": "user",
"content": [{"type": "text", "text": "world"}],
});
assert_eq!(realtime_text_from_conversation_item(&user), None);
assert_eq!(
realtime_text_from_conversation_item(&user),
Some("world".to_string())
);
}
#[test]
fn extracts_and_concatenates_text_entries_only() {
let item = json!({
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "a"},
{"type": "ignored", "text": "x"},
@@ -443,31 +436,8 @@ mod tests {
let no_text = json!({
"type": "message",
"role": "assistant",
"content": [{"type": "other", "value": 1}],
});
assert_eq!(realtime_text_from_conversation_item(&no_text), None);
let empty_spawn_transcript = json!({
"type": "spawn_transcript",
"delta_user_transcript": "",
});
assert_eq!(
realtime_text_from_conversation_item(&empty_spawn_transcript),
None
);
}
#[test]
fn extracts_text_from_spawn_transcript_items() {
let item = json!({
"type": "spawn_transcript",
"delta_user_transcript": "delegate from transcript",
"backend_prompt_messages": [{"role": "user", "content": "delegate from transcript"}],
});
assert_eq!(
realtime_text_from_conversation_item(&item),
Some("delegate from transcript".to_string())
);
}
}

View File

@@ -566,7 +566,7 @@ fn message_input_texts(body: &Value, role: &str) -> Vec<String> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> {
async fn inbound_realtime_text_starts_turn_and_ignores_role() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
@@ -589,7 +589,7 @@ async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> {
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "assistant",
"role": "user",
"content": [{"type": "text", "text": "text from realtime"}]
}
}),
@@ -633,321 +633,6 @@ async fn inbound_realtime_text_starts_turn_for_assistant_role() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.created",
"session": { "id": "sess_ignore_user_role" }
}),
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "text", "text": "echoed local text"}]
}
}),
json!({
"type": "response.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"num_channels": 1
}),
]]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
}
});
let test = builder.build(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::SessionCreated { session_id },
}) if session_id == "sess_ignore_user_role" => Some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio after user-role conversation item");
assert_eq!(audio_out.data, "AQID");
let unexpected_turn_started = tokio::time::timeout(
Duration::from_millis(200),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::TurnStarted(_) => Some(()),
_ => None,
}),
)
.await;
assert!(unexpected_turn_started.is_err());
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_audio() -> Result<()>
{
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_assistant_message(
"msg-1",
"assistant says hi",
)),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let realtime_server = start_websocket_server(vec![vec![
vec![
json!({
"type": "session.created",
"session": { "id": "sess_echo_guard" }
}),
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "delegate now"}]
}
}),
],
vec![
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "user",
"content": [{"type": "text", "text": "assistant says hi"}]
}
}),
json!({
"type": "response.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"num_channels": 1
}),
],
]])
.await;
let mut builder = test_codex().with_model("gpt-5.1").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
}
});
let test = builder.build_with_streaming_server(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::SessionCreated { session_id },
}) if session_id == "sess_echo_guard" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemAdded(item),
}) => item
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now"))
.then_some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio after echoed user-role message");
assert_eq!(audio_out.data, "AQID");
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
let _ = gate_completed_tx.send(());
completion
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = api_server.requests().await;
assert_eq!(requests.len(), 1);
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_realtime_text_does_not_block_realtime_event_forwarding() -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.created",
"session": { "id": "sess_non_blocking" }
}),
json!({
"type": "conversation.item.added",
"item": {
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "delegate now"}]
}
}),
json!({
"type": "response.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"num_channels": 1
}),
]]])
.await;
let mut builder = test_codex().with_model("gpt-5.1").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
}
});
let test = builder.build_with_streaming_server(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::SessionCreated { session_id },
}) if session_id == "sess_non_blocking" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemAdded(item),
}) => item
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|content| content.get("text").and_then(Value::as_str) == Some("delegate now"))
.then_some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio while delegated turn was still pending");
assert_eq!(audio_out.data, "AQID");
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
let _ = gate_completed_tx.send(());
completion
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_realtime_text_steers_active_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1106,121 +791,3 @@ async fn inbound_realtime_text_steers_active_turn() -> Result<()> {
api_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inbound_spawn_transcript_starts_turn_and_does_not_block_realtime_audio() -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(responses::ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(responses::ev_completed("resp-1")),
},
];
let (api_server, completions) = start_streaming_sse_server(vec![first_chunks]).await;
let delegated_text = "delegate from spawn transcript";
let realtime_server = start_websocket_server(vec![vec![vec![
json!({
"type": "session.created",
"session": { "id": "sess_spawn_transcript" }
}),
json!({
"type": "conversation.item.added",
"item": {
"type": "spawn_transcript",
"seq": 1,
"full_user_transcript": delegated_text,
"delta_user_transcript": delegated_text,
"backend_prompt_messages": [{
"role": "user",
"channel": null,
"content": delegated_text,
"content_type": "text"
}],
"transcript_source": "backend_prompt_messages"
}
}),
json!({
"type": "response.output_audio.delta",
"delta": "AQID",
"sample_rate": 24000,
"num_channels": 1
}),
]]])
.await;
let mut builder = test_codex().with_model("gpt-5.1").with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
}
});
let test = builder.build_with_streaming_server(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: "backend prompt".to_string(),
session_id: None,
}))
.await?;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::SessionCreated { session_id },
}) if session_id == "sess_spawn_transcript" => Some(()),
_ => None,
})
.await;
let _ = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::ConversationItemAdded(item),
}) => (item.get("type").and_then(Value::as_str) == Some("spawn_transcript")
&& item.get("delta_user_transcript").and_then(Value::as_str) == Some(delegated_text))
.then_some(()),
_ => None,
})
.await;
let audio_out = tokio::time::timeout(
Duration::from_millis(500),
wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::AudioOut(frame),
}) => Some(frame.clone()),
_ => None,
}),
)
.await
.expect("timed out waiting for realtime audio after spawn_transcript");
assert_eq!(audio_out.data, "AQID");
let completion = completions
.into_iter()
.next()
.expect("missing delegated turn completion");
let _ = gate_completed_tx.send(());
completion
.await
.expect("delegated turn request did not complete");
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = api_server.requests().await;
assert_eq!(requests.len(), 1);
let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request");
let first_texts = message_input_texts(&first_body, "user");
assert!(first_texts.iter().any(|text| text == delegated_text));
realtime_server.shutdown().await;
api_server.shutdown().await;
Ok(())
}

View File

@@ -431,7 +431,7 @@ fn build_authorize_url(
("redirect_uri".to_string(), redirect_uri.to_string()),
(
"scope".to_string(),
"openid profile email offline_access api.model.audio.request".to_string(),
"openid profile email offline_access".to_string(),
),
(
"code_challenge".to_string(),

View File

@@ -14,15 +14,13 @@ libc = { workspace = true }
serde_json = { workspace = true }
path-absolutize = { workspace = true }
serde = { workspace = true, features = ["derive"] }
socket2 = { workspace = true, features = ["all"] }
socket2 = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"net",
"macros",
"process",
"rt-multi-thread",
"signal",
"time",
] }
tokio-util = { workspace = true }
tracing = { workspace = true }

View File

@@ -1,114 +1,21 @@
#[cfg(unix)]
pub mod unix;
mod unix {
mod escalate_client;
mod escalate_protocol;
mod escalate_server;
mod escalation_policy;
mod socket;
mod stopwatch;
#[cfg(unix)]
pub use unix::*;
#[cfg(unix)]
pub use unix::escalate_client::run;
#[cfg(unix)]
pub use unix::escalate_protocol::EscalateAction;
#[cfg(unix)]
pub use unix::escalate_server::EscalationPolicyFactory;
#[cfg(unix)]
pub use unix::escalate_server::ExecParams;
#[cfg(unix)]
pub use unix::escalate_server::ExecResult;
#[cfg(unix)]
pub use unix::escalation_policy::EscalationPolicy;
#[cfg(unix)]
pub use unix::stopwatch::Stopwatch;
#[cfg(unix)]
mod legacy_api {
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_execpolicy::Policy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::SandboxPermissions as ProtocolSandboxPermissions;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use crate::unix::escalate_server::EscalationPolicyFactory;
use crate::unix::escalate_server::ExecParams;
use crate::unix::escalate_server::ExecResult;
use crate::unix::escalate_server::SandboxState;
use crate::unix::escalate_server::ShellCommandExecutor;
struct CoreShellCommandExecutor;
#[async_trait::async_trait]
impl ShellCommandExecutor for CoreShellCommandExecutor {
async fn run(
&self,
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
cancel_rx: CancellationToken,
sandbox_state: &SandboxState,
) -> anyhow::Result<ExecResult> {
let result = codex_core::exec::process_exec_tool_call(
codex_core::exec::ExecParams {
command,
cwd,
expiration: codex_core::exec::ExecExpiration::Cancellation(cancel_rx),
env,
network: None,
sandbox_permissions: ProtocolSandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
justification: None,
arg0: None,
},
&sandbox_state.sandbox_policy,
&sandbox_state.sandbox_cwd,
&sandbox_state.codex_linux_sandbox_exe,
sandbox_state.use_linux_sandbox_bwrap,
None,
)
.await?;
Ok(ExecResult {
exit_code: result.exit_code,
output: result.aggregated_output.text,
duration: result.duration,
timed_out: result.timed_out,
})
}
}
#[allow(clippy::too_many_arguments)]
pub async fn run_escalate_server(
exec_params: ExecParams,
sandbox_state: &codex_core::SandboxState,
shell_program: impl AsRef<Path>,
execve_wrapper: impl AsRef<Path>,
policy: Arc<RwLock<Policy>>,
escalation_policy_factory: impl EscalationPolicyFactory,
effective_timeout: Duration,
) -> anyhow::Result<ExecResult> {
let sandbox_state = SandboxState {
sandbox_policy: sandbox_state.sandbox_policy.clone(),
codex_linux_sandbox_exe: sandbox_state.codex_linux_sandbox_exe.clone(),
sandbox_cwd: sandbox_state.sandbox_cwd.clone(),
use_linux_sandbox_bwrap: sandbox_state.use_linux_sandbox_bwrap,
};
crate::unix::escalate_server::run_escalate_server(
exec_params,
&sandbox_state,
shell_program,
execve_wrapper,
policy,
escalation_policy_factory,
effective_timeout,
&CoreShellCommandExecutor,
)
.await
}
pub use self::escalate_client::run;
pub use self::escalate_protocol::EscalateAction;
pub use self::escalate_server::EscalationPolicyFactory;
pub use self::escalate_server::ExecParams;
pub use self::escalate_server::ExecResult;
pub use self::escalate_server::run_escalate_server;
pub use self::escalation_policy::EscalationPolicy;
pub use self::stopwatch::Stopwatch;
}
#[cfg(unix)]
pub use legacy_api::run_escalate_server;
pub use unix::*;

View File

@@ -40,7 +40,7 @@ impl ShellPolicyFactory {
}
}
pub struct ShellEscalationPolicy {
struct ShellEscalationPolicy {
provider: Arc<dyn ShellActionProvider>,
stopwatch: Stopwatch,
}

View File

@@ -7,8 +7,8 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use codex_core::SandboxState;
use codex_execpolicy::Policy;
use codex_protocol::protocol::SandboxPolicy;
use path_absolutize::Absolutize as _;
use tokio::process::Command;
use tokio::sync::RwLock;
@@ -27,33 +27,6 @@ use crate::unix::socket::AsyncDatagramSocket;
use crate::unix::socket::AsyncSocket;
use crate::unix::stopwatch::Stopwatch;
#[derive(Debug, Clone)]
/// Sandbox configuration forwarded to the embedding crate's process executor.
pub struct SandboxState {
pub sandbox_policy: SandboxPolicy,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub sandbox_cwd: PathBuf,
pub use_linux_sandbox_bwrap: bool,
}
#[async_trait::async_trait]
/// Adapter for running the shell command after the escalation server has been set up.
///
/// This lets `shell-escalation` own the Unix escalation protocol while the caller
/// (for example `codex-core` or `exec-server`) keeps control over process spawning,
/// output capture, and sandbox integration.
pub trait ShellCommandExecutor: Send + Sync {
/// Runs the requested shell command and returns the captured result.
async fn run(
&self,
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
cancel_rx: CancellationToken,
sandbox_state: &SandboxState,
) -> anyhow::Result<ExecResult>;
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct ExecParams {
/// The bash string to execute.
@@ -98,12 +71,11 @@ impl EscalateServer {
params: ExecParams,
cancel_rx: CancellationToken,
sandbox_state: &SandboxState,
command_executor: &dyn ShellCommandExecutor,
) -> anyhow::Result<ExecResult> {
let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?;
let client_socket = escalate_client.into_inner();
// Only the client endpoint should cross exec into the wrapper process.
client_socket.set_cloexec(false)?;
let escalate_task = tokio::spawn(escalate_task(escalate_server, self.policy.clone()));
let mut env = std::env::vars().collect::<HashMap<String, String>>();
env.insert(
@@ -119,27 +91,47 @@ impl EscalateServer {
self.execve_wrapper.to_string_lossy().to_string(),
);
let command = vec![
self.bash_path.to_string_lossy().to_string(),
if params.login == Some(false) {
"-c".to_string()
} else {
"-lc".to_string()
},
params.command,
];
let result = command_executor
.run(
command,
PathBuf::from(&params.workdir),
let ExecParams {
command,
workdir,
timeout_ms: _,
login,
} = params;
let result = codex_core::exec::process_exec_tool_call(
codex_core::exec::ExecParams {
command: vec![
self.bash_path.to_string_lossy().to_string(),
if login == Some(false) {
"-c".to_string()
} else {
"-lc".to_string()
},
command,
],
cwd: PathBuf::from(&workdir),
expiration: codex_core::exec::ExecExpiration::Cancellation(cancel_rx),
env,
cancel_rx,
sandbox_state,
)
.await?;
network: None,
sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
arg0: None,
},
&sandbox_state.sandbox_policy,
&sandbox_state.sandbox_cwd,
&sandbox_state.codex_linux_sandbox_exe,
sandbox_state.use_linux_sandbox_bwrap,
None,
)
.await?;
escalate_task.abort();
Ok(result)
Ok(ExecResult {
exit_code: result.exit_code,
output: result.aggregated_output.text,
duration: result.duration,
timed_out: result.timed_out,
})
}
}
@@ -150,7 +142,6 @@ pub trait EscalationPolicyFactory {
fn create_policy(&self, policy: Arc<RwLock<Policy>>, stopwatch: Stopwatch) -> Self::Policy;
}
#[allow(clippy::too_many_arguments)]
pub async fn run_escalate_server(
exec_params: ExecParams,
sandbox_state: &SandboxState,
@@ -159,7 +150,6 @@ pub async fn run_escalate_server(
policy: Arc<RwLock<Policy>>,
escalation_policy_factory: impl EscalationPolicyFactory,
effective_timeout: Duration,
command_executor: &dyn ShellCommandExecutor,
) -> anyhow::Result<ExecResult> {
let stopwatch = Stopwatch::new(effective_timeout);
let cancel_token = stopwatch.cancellation_token();
@@ -170,7 +160,7 @@ pub async fn run_escalate_server(
);
escalate_server
.exec(exec_params, cancel_token, sandbox_state, command_executor)
.exec(exec_params, cancel_token, sandbox_state)
.await
}
@@ -282,7 +272,6 @@ async fn handle_escalate_session_with_policy(
.await?;
}
}
Ok(())
}
@@ -290,6 +279,7 @@ async fn handle_escalate_session_with_policy(
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;

View File

@@ -1,7 +1,7 @@
pub mod core_shell_escalation;
pub mod escalate_client;
pub mod escalate_protocol;
pub mod escalate_server;
pub mod escalation_policy;
pub mod socket;
pub mod core_shell_escalation;
pub mod stopwatch;

View File

@@ -96,8 +96,8 @@ async fn read_frame_header(
while filled < LENGTH_PREFIX_SIZE {
let mut guard = async_socket.readable().await?;
// The first read should come with a control message containing any FDs.
let read = if !captured_control {
match guard.try_io(|inner| {
let result = if !captured_control {
guard.try_io(|inner| {
let mut bufs = [MaybeUninitSlice::new(&mut header[filled..])];
let (read, control_len) = {
let mut msg = MsgHdrMut::new()
@@ -109,18 +109,16 @@ async fn read_frame_header(
control.truncate(control_len);
captured_control = true;
Ok(read)
}) {
Ok(Ok(read)) => read,
Ok(Err(err)) => return Err(err),
Err(_would_block) => continue,
}
})
} else {
match guard.try_io(|inner| inner.get_ref().recv(&mut header[filled..])) {
Ok(Ok(read)) => read,
Ok(Err(err)) => return Err(err),
Err(_would_block) => continue,
}
guard.try_io(|inner| inner.get_ref().recv(&mut header[filled..]))
};
let Ok(result) = result else {
// Would block, try again.
continue;
};
let read = result?;
if read == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
@@ -152,11 +150,12 @@ async fn read_frame_payload(
let mut filled = 0;
while filled < message_len {
let mut guard = async_socket.readable().await?;
let read = match guard.try_io(|inner| inner.get_ref().recv(&mut payload[filled..])) {
Ok(Ok(read)) => read,
Ok(Err(err)) => return Err(err),
Err(_would_block) => continue,
let result = guard.try_io(|inner| inner.get_ref().recv(&mut payload[filled..]));
let Ok(result) = result else {
// Would block, try again.
continue;
};
let read = result?;
if read == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
@@ -262,13 +261,7 @@ impl AsyncSocket {
}
pub fn pair() -> std::io::Result<(AsyncSocket, AsyncSocket)> {
// `socket2::Socket::pair()` also applies "common flags" (including
// `SO_NOSIGPIPE` on Apple platforms), which can fail for AF_UNIX sockets.
// Use `pair_raw()` to avoid those side effects, then restore `CLOEXEC`
// explicitly on both endpoints.
let (server, client) = Socket::pair_raw(Domain::UNIX, Type::STREAM, None)?;
server.set_cloexec(true)?;
client.set_cloexec(true)?;
let (server, client) = Socket::pair(Domain::UNIX, Type::STREAM, None)?;
Ok((AsyncSocket::new(server)?, AsyncSocket::new(client)?))
}
@@ -321,11 +314,11 @@ async fn send_stream_frame(
let mut include_fds = !fds.is_empty();
while written < frame.len() {
let mut guard = socket.writable().await?;
let bytes_written = match guard
.try_io(|inner| send_stream_chunk(inner.get_ref(), &frame[written..], fds, include_fds))
{
Ok(Ok(bytes_written)) => bytes_written,
Ok(Err(err)) => return Err(err),
let result = guard.try_io(|inner| {
send_stream_chunk(inner.get_ref(), &frame[written..], fds, include_fds)
});
let bytes_written = match result {
Ok(bytes_written) => bytes_written?,
Err(_would_block) => continue,
};
if bytes_written == 0 {
@@ -377,13 +370,7 @@ impl AsyncDatagramSocket {
}
pub fn pair() -> std::io::Result<(Self, Self)> {
// `socket2::Socket::pair()` also applies "common flags" (including
// `SO_NOSIGPIPE` on Apple platforms), which can fail for AF_UNIX sockets.
// Use `pair_raw()` to avoid those side effects, then restore `CLOEXEC`
// explicitly on both endpoints.
let (server, client) = Socket::pair_raw(Domain::UNIX, Type::DGRAM, None)?;
server.set_cloexec(true)?;
client.set_cloexec(true)?;
let (server, client) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?;
Ok((Self::new(server)?, Self::new(client)?))
}
@@ -485,7 +472,7 @@ mod tests {
#[test]
fn send_datagram_bytes_rejects_excessive_fd_counts() -> std::io::Result<()> {
let (socket, _peer) = Socket::pair_raw(Domain::UNIX, Type::DGRAM, None)?;
let (socket, _peer) = Socket::pair(Domain::UNIX, Type::DGRAM, None)?;
let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?;
let err = send_datagram_bytes(&socket, b"hi", &fds).unwrap_err();
assert_eq!(std::io::ErrorKind::InvalidInput, err.kind());
@@ -494,7 +481,7 @@ mod tests {
#[test]
fn send_stream_chunk_rejects_excessive_fd_counts() -> std::io::Result<()> {
let (socket, _peer) = Socket::pair_raw(Domain::UNIX, Type::STREAM, None)?;
let (socket, _peer) = Socket::pair(Domain::UNIX, Type::STREAM, None)?;
let fds = fd_list(MAX_FDS_PER_MESSAGE + 1)?;
let err = send_stream_chunk(&socket, b"hello", &fds, true).unwrap_err();
assert_eq!(std::io::ErrorKind::InvalidInput, err.kind());

View File

@@ -13,12 +13,10 @@ name = "codex_tui"
path = "src/lib.rs"
[features]
default = ["voice-input"]
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
vt100-tests = []
# Gate verbose debug logging inside the TUI implementation.
debug-logs = []
voice-input = ["dep:cpal", "dep:hound"]
[lints]
workspace = true
@@ -70,7 +68,7 @@ ratatui = { workspace = true, features = [
] }
ratatui-macros = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart"] }
reqwest = { version = "0.12", features = ["json"] }
rmcp = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, features = ["preserve_order"] }
@@ -106,10 +104,6 @@ uuid = { workspace = true }
codex-windows-sandbox = { workspace = true }
tokio-util = { workspace = true, features = ["time"] }
[target.'cfg(not(target_os = "linux"))'.dependencies]
cpal = { version = "0.15", optional = true }
hound = { version = "3.5", optional = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }

View File

@@ -1547,8 +1547,6 @@ impl App {
{
return Ok(AppRunControl::Continue);
}
// Allow widgets to process any pending timers before rendering.
self.chat_widget.pre_draw_tick();
tui.draw(
self.chat_widget.desired_height(tui.terminal.size()?.width),
|frame| {
@@ -2705,22 +2703,6 @@ impl App {
));
}
},
#[cfg(not(target_os = "linux"))]
AppEvent::TranscriptionComplete { id, text } => {
self.chat_widget.replace_transcription(&id, &text);
}
#[cfg(not(target_os = "linux"))]
AppEvent::TranscriptionFailed { id, error: _ } => {
self.chat_widget.remove_transcription_placeholder(&id);
}
#[cfg(not(target_os = "linux"))]
AppEvent::UpdateRecordingMeter { id, text } => {
// Update in place to preserve the element id for subsequent frames.
let updated = self.chat_widget.update_transcription_in_place(&id, &text);
if updated {
tui.frame_requester().schedule_frame();
}
}
AppEvent::StatusLineSetup { items } => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
let edit = codex_core::config::edit::status_line_items_edit(&ids);
@@ -3124,7 +3106,7 @@ impl App {
self.chat_widget.handle_key_event(key_event);
}
_ => {
self.chat_widget.handle_key_event(key_event);
// Ignore Release key events.
}
};
}

View File

@@ -321,29 +321,6 @@ pub(crate) enum AppEvent {
/// Re-open the permissions presets popup.
OpenPermissionsPopup,
/// Live update for the in-progress voice recording placeholder. Carries
/// the placeholder `id` and the text to display (e.g., an ASCII meter).
#[cfg(not(target_os = "linux"))]
UpdateRecordingMeter {
id: String,
text: String,
},
/// Voice transcription finished for the given placeholder id.
#[cfg(not(target_os = "linux"))]
TranscriptionComplete {
id: String,
text: String,
},
/// Voice transcription failed; remove the placeholder identified by `id`.
#[cfg(not(target_os = "linux"))]
TranscriptionFailed {
id: String,
#[allow(dead_code)]
error: String,
},
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),

View File

@@ -109,17 +109,6 @@
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
//!
//! # Voice Hold-To-Talk Without Key Release
//!
//! On terminals that do not report `KeyEventKind::Release`, space hold-to-talk uses repeated
//! space key events as "still held" evidence:
//!
//! - For pending holds (non-empty composer), if timeout elapses without any repeated space event,
//! we treat the key as a normal typed space.
//! - If repeated space events are seen before timeout, we proceed with hold-to-talk.
//! - While recording, repeated space events keep the recording alive; if they stop for a short
//! window, we stop and transcribe.
use crate::bottom_pane::footer::mode_indicator_line;
use crate::key_hint;
use crate::key_hint::KeyBinding;
@@ -202,7 +191,6 @@ use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::history_cell;
use crate::tui::FrameRequester;
use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_chatgpt::connectors;
use codex_chatgpt::connectors::AppInfo;
@@ -214,17 +202,9 @@ 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;
@@ -304,35 +284,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>,
@@ -348,14 +299,10 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
large_paste_counters: HashMap<usize, usize>,
has_focus: bool,
frame_requester: Option<FrameRequester>,
/// Invariant: attached images are labeled in vec order as
/// `[Image #M+1]..[Image #N]`, where `M` is the number of remote images.
attached_images: Vec<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,
@@ -373,9 +320,6 @@ pub(crate) struct ChatComposer {
selected_remote_image_index: Option<usize>,
footer_flash: Option<FooterFlash>,
context_window_percent: Option<i64>,
// Monotonically increasing identifier for textarea elements we insert.
#[cfg(not(target_os = "linux"))]
next_element_id: u64,
context_window_used_tokens: Option<i64>,
skills: Option<Vec<SkillMetadata>>,
connectors_snapshot: Option<ConnectorsSnapshot>,
@@ -463,11 +407,8 @@ impl ChatComposer {
pending_pastes: Vec::new(),
large_paste_counters: HashMap::new(),
has_focus: has_input_focus,
frame_requester: None,
attached_images: Vec::new(),
placeholder_text,
voice_state: VoiceState::new(enhanced_keys_supported),
spinner_stop_flags: HashMap::new(),
is_task_running: false,
input_enabled: true,
input_disabled_placeholder: None,
@@ -480,8 +421,6 @@ impl ChatComposer {
selected_remote_image_index: None,
footer_flash: None,
context_window_percent: None,
#[cfg(not(target_os = "linux"))]
next_element_id: 0,
context_window_used_tokens: None,
skills: None,
connectors_snapshot: None,
@@ -503,17 +442,6 @@ impl ChatComposer {
this
}
#[cfg(not(target_os = "linux"))]
fn next_id(&mut self) -> String {
let id = self.next_element_id;
self.next_element_id = self.next_element_id.wrapping_add(1);
id.to_string()
}
pub(crate) fn set_frame_requester(&mut self, frame_requester: FrameRequester) {
self.frame_requester = Some(frame_requester);
}
pub fn set_skill_mentions(&mut self, skills: Option<Vec<SkillMetadata>>) {
self.skills = skills;
}
@@ -577,23 +505,6 @@ impl ChatComposer {
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.personality_command_enabled = enabled;
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.voice_state.transcription_enabled = enabled;
if !enabled {
self.voice_state.space_hold_started_at = None;
if let Some(id) = self.voice_state.space_hold_element_id.take() {
let _ = self.textarea.replace_element_by_id(&id, " ");
}
self.voice_state.space_hold_trigger = None;
self.voice_state.space_hold_repeat_seen = false;
}
}
#[cfg(not(target_os = "linux"))]
fn voice_transcription_enabled(&self) -> bool {
self.voice_state.transcription_enabled && cfg!(not(target_os = "linux"))
}
/// Centralized feature gating keeps config checks out of call sites.
fn popups_enabled(&self) -> bool {
self.config.popups_enabled
@@ -657,20 +568,6 @@ impl ChatComposer {
}
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
return None;
}
// Hide the cursor while recording voice input.
#[cfg(not(target_os = "linux"))]
if self.voice_state.voice.is_some() {
return None;
}
let [_, _, textarea_rect, _] = self.layout_areas(area);
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
/// Returns true if the composer currently contains no user-entered input.
pub(crate) fn is_empty(&self) -> bool {
self.textarea.is_empty()
@@ -724,10 +621,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 {
@@ -740,8 +633,9 @@ impl ChatComposer {
{
self.textarea.insert_str(" ");
} else {
self.insert_str(&pasted);
self.textarea.insert_str(&pasted);
}
// Explicit paste events should not trigger Enter suppression.
self.paste_burst.clear_after_explicit_paste();
self.sync_popups();
true
@@ -972,9 +866,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();
@@ -1233,56 +1124,20 @@ impl ChatComposer {
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if matches!(key_event.kind, KeyEventKind::Release) {
self.voice_state.key_release_supported = true;
}
// Timer-based conversion is handled in the pre-draw tick.
// If recording, stop on Space release when supported. On terminals without key-release
// events, Space repeat events are handled as "still held" and stop is driven by timeout
// in `process_space_hold_trigger`.
if let Some(result) = self.handle_key_event_while_recording(key_event) {
return result;
}
if !self.input_enabled {
return (InputResult::None, false);
}
// Outside of recording, ignore all key releases globally except for Space,
// which is handled explicitly for hold-to-talk behavior below.
if matches!(key_event.kind, KeyEventKind::Release)
&& !matches!(key_event.code, KeyCode::Char(' '))
{
return (InputResult::None, false);
}
// If a space hold is pending and another non-space key is pressed, cancel the hold
// and convert the element into a plain space.
if self.voice_state.space_hold_started_at.is_some()
&& !matches!(key_event.code, KeyCode::Char(' '))
{
self.voice_state.space_hold_started_at = None;
if let Some(id) = self.voice_state.space_hold_element_id.take() {
let _ = self.textarea.replace_element_by_id(&id, " ");
}
self.voice_state.space_hold_trigger = None;
self.voice_state.space_hold_repeat_seen = false;
// fall through to normal handling of this other key
}
if let Some(result) = self.handle_voice_space_key_event(&key_event) {
return result;
}
let result = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_popups();
result
}
@@ -2680,7 +2535,6 @@ impl ChatComposer {
// -------------------------------------------------------------
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}
| KeyEvent {
@@ -2734,136 +2588,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('!')
}
@@ -2883,6 +2607,8 @@ impl ChatComposer {
true
}
FlushResult::Typed(ch) => {
// Mirror insert_str() behavior so popups stay in sync when a
// pending fast char flushes as normal typed input.
self.textarea.insert_str(ch.to_string().as_str());
self.sync_popups();
true
@@ -2906,12 +2632,6 @@ impl ChatComposer {
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
/// timestamp to time out against.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Ignore key releases here to avoid treating them as additional input
// (e.g., appending the same character twice via paste-burst logic).
if !matches!(input.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return (InputResult::None, false);
}
self.handle_input_basic_with_time(input, Instant::now())
}
@@ -3177,7 +2897,7 @@ impl ChatComposer {
.map(|items| if items.is_empty() { 0 } else { 1 })
}
pub(crate) fn sync_popups(&mut self) {
fn sync_popups(&mut self) {
self.sync_slash_command_elements();
if !self.popups_enabled() {
self.active_popup = ActivePopup::None;
@@ -3591,11 +3311,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;
@@ -3629,32 +3344,6 @@ impl ChatComposer {
}
}
#[cfg(not(target_os = "linux"))]
fn schedule_space_hold_timer(flag: Arc<AtomicBool>, frame: Option<FrameRequester>) {
const HOLD_DELAY_MILLIS: u64 = 500;
if let Ok(handle) = Handle::try_current() {
let flag_clone = flag;
let frame_clone = frame;
handle.spawn(async move {
tokio::time::sleep(Duration::from_millis(HOLD_DELAY_MILLIS)).await;
Self::complete_space_hold_timer(flag_clone, frame_clone);
});
} else {
thread::spawn(move || {
thread::sleep(Duration::from_millis(HOLD_DELAY_MILLIS));
Self::complete_space_hold_timer(flag, frame);
});
}
}
#[cfg(not(target_os = "linux"))]
fn complete_space_hold_timer(flag: Arc<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;
@@ -3672,280 +3361,6 @@ impl ChatComposer {
}
}
#[cfg(not(target_os = "linux"))]
impl ChatComposer {
pub(crate) fn process_space_hold_trigger(&mut self) {
if self.voice_transcription_enabled()
&& let Some(flag) = self.voice_state.space_hold_trigger.as_ref()
&& flag.load(Ordering::Relaxed)
&& self.voice_state.space_hold_started_at.is_some()
&& self.voice_state.voice.is_none()
{
let _ = self.on_space_hold_timeout();
}
const SPACE_REPEAT_INITIAL_GRACE_MILLIS: u64 = 700;
const SPACE_REPEAT_IDLE_TIMEOUT_MILLIS: u64 = 250;
if !self.voice_state.key_release_supported && self.voice_state.voice.is_some() {
let now = Instant::now();
let initial_grace = Duration::from_millis(SPACE_REPEAT_INITIAL_GRACE_MILLIS);
let repeat_idle_timeout = Duration::from_millis(SPACE_REPEAT_IDLE_TIMEOUT_MILLIS);
if let Some(started_at) = self.voice_state.space_recording_started_at
&& now.saturating_duration_since(started_at) >= initial_grace
{
let should_stop = match self.voice_state.space_recording_last_repeat_at {
Some(last_repeat_at) => {
now.saturating_duration_since(last_repeat_at) >= repeat_idle_timeout
}
None => true,
};
if should_stop {
let _ = self.stop_recording_and_start_transcription();
}
}
}
}
/// Called when the 500ms space hold timeout elapses.
///
/// On terminals without key-release reporting, this only transitions into voice capture if we
/// observed repeated Space events while pending; otherwise the keypress is treated as a typed
/// space.
pub(crate) fn on_space_hold_timeout(&mut self) -> bool {
if !self.voice_transcription_enabled() {
return false;
}
if self.voice_state.voice.is_some() {
return false;
}
if self.voice_state.space_hold_started_at.is_some() {
if !self.voice_state.key_release_supported && !self.voice_state.space_hold_repeat_seen {
if let Some(id) = self.voice_state.space_hold_element_id.take() {
let _ = self.textarea.replace_element_by_id(&id, " ");
}
self.voice_state.space_hold_started_at = None;
self.voice_state.space_hold_trigger = None;
self.voice_state.space_hold_repeat_seen = false;
return true;
}
// Preserve the typed space when transitioning into voice capture, but
// avoid duplicating an existing trailing space. In either case,
// convert/remove the temporary named element before inserting the
// recording/transcribing placeholder.
if let Some(id) = self.voice_state.space_hold_element_id.take() {
let replacement = if self
.textarea
.named_element_range(&id)
.and_then(|range| self.textarea.text()[..range.start].chars().next_back())
.is_some_and(|ch| ch == ' ')
{
""
} else {
" "
};
let _ = self.textarea.replace_element_by_id(&id, replacement);
}
// Clear pending state before starting capture
self.voice_state.space_hold_started_at = None;
self.voice_state.space_hold_trigger = None;
self.voice_state.space_hold_repeat_seen = false;
// Start voice capture
self.start_recording_with_placeholder()
} else {
false
}
}
/// Stop recording if active, update the placeholder, and spawn background transcription.
/// Returns true if the UI should redraw.
fn stop_recording_and_start_transcription(&mut self) -> bool {
let Some(vc) = self.voice_state.voice.take() else {
return false;
};
self.voice_state.space_recording_started_at = None;
self.voice_state.space_recording_last_repeat_at = None;
match vc.stop() {
Ok(audio) => {
// If the recording is too short, remove the placeholder immediately
// and skip the transcribing state entirely.
let total_samples = audio.data.len() as f32;
let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32);
let duration_seconds = if samples_per_second > 0.0 {
total_samples / samples_per_second
} else {
0.0
};
const MIN_DURATION_SECONDS: f32 = 1.0;
if duration_seconds < MIN_DURATION_SECONDS {
if let Some(id) = self.voice_state.recording_placeholder_id.take() {
let _ = self.textarea.replace_element_by_id(&id, "");
}
return true;
}
// Otherwise, update the placeholder to show a spinner and proceed.
let id = match self.voice_state.recording_placeholder_id.take() {
Some(id) => id,
None => self.next_id(),
};
let placeholder_range = self.textarea.named_element_range(&id);
let prompt_source = if let Some(range) = &placeholder_range {
self.textarea.text()[..range.start].to_string()
} else {
self.textarea.text().to_string()
};
// Initialize with first spinner frame immediately.
let _ = self.textarea.update_named_element_by_id(&id, "");
// Spawn animated braille spinner until transcription finishes (or times out).
self.spawn_transcribing_spinner(id.clone());
let tx = self.app_event_tx.clone();
crate::voice::transcribe_async(id, audio, Some(prompt_source), tx);
true
}
Err(e) => {
tracing::error!("failed to stop voice capture: {e}");
true
}
}
}
/// Start voice capture and insert a placeholder element for the live meter.
/// Returns true if recording began and UI should redraw; false on failure.
fn start_recording_with_placeholder(&mut self) -> bool {
match crate::voice::VoiceCapture::start() {
Ok(vc) => {
self.voice_state.voice = Some(vc);
if self.voice_state.key_release_supported {
self.voice_state.space_recording_started_at = None;
} else {
self.voice_state.space_recording_started_at = Some(Instant::now());
}
self.voice_state.space_recording_last_repeat_at = None;
// Insert visible placeholder for the meter (no label)
let id = self.next_id();
self.textarea.insert_named_element("", id.clone());
self.voice_state.recording_placeholder_id = Some(id);
// Spawn metering animation
if let Some(v) = &self.voice_state.voice {
let data = v.data_arc();
let stop = v.stopped_flag();
let sr = v.sample_rate();
let ch = v.channels();
let peak = v.last_peak_arc();
if let Some(idref) = &self.voice_state.recording_placeholder_id {
self.spawn_recording_meter(idref.clone(), sr, ch, data, peak, stop);
}
}
true
}
Err(e) => {
self.voice_state.space_recording_started_at = None;
self.voice_state.space_recording_last_repeat_at = None;
tracing::error!("failed to start voice capture: {e}");
false
}
}
}
fn spawn_recording_meter(
&self,
id: String,
_sample_rate: u32,
_channels: u16,
_data: Arc<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 {
self.textarea.update_named_element_by_id(id, text)
}
pub fn remove_transcription_placeholder(&mut self, id: &str) {
self.stop_transcription_spinner(id);
let _ = self.textarea.replace_element_by_id(id, "");
}
}
fn skill_display_name(skill: &SkillMetadata) -> &str {
skill
.interface
@@ -4372,15 +3787,6 @@ fn prompt_selection_action(
}
}
impl Drop for ChatComposer {
fn drop(&mut self) {
// Stop any running spinner tasks.
for (_id, flag) in self.spinner_stop_flags.drain() {
flag.store(true, Ordering::Relaxed);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -6121,19 +5527,11 @@ mod tests {
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
for &ch in chars {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
std::thread::sleep(ChatComposer::recommended_paste_flush_delay());
let _ = composer.flush_paste_burst_if_due();
if ch == ' ' {
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Char(' '),
KeyModifiers::NONE,
KeyEventKind::Release,
));
}
}
}
@@ -6223,195 +5621,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(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
true,
);
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Char(' '),
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert_eq!("x ", composer.textarea.text());
assert!(composer.voice_state.space_hold_started_at.is_none());
assert!(composer.voice_state.space_hold_element_id.is_none());
assert!(composer.voice_state.space_hold_trigger.is_none());
assert!(!composer.voice_state.space_hold_repeat_seen);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn space_hold_timeout_without_release_or_repeat_keeps_typed_space() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_voice_transcription_enabled(true);
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
let elem_id = "space-hold".to_string();
composer.textarea.insert_named_element(" ", elem_id.clone());
composer.voice_state.space_hold_started_at = Some(Instant::now());
composer.voice_state.space_hold_element_id = Some(elem_id);
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
composer.voice_state.key_release_supported = false;
composer.voice_state.space_hold_repeat_seen = false;
assert_eq!("x ", composer.textarea.text());
composer.process_space_hold_trigger();
assert_eq!("x ", composer.textarea.text());
assert!(composer.voice_state.space_hold_started_at.is_none());
assert!(!composer.voice_state.space_hold_repeat_seen);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn space_hold_timeout_with_repeat_uses_hold_path_without_release() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_voice_transcription_enabled(true);
composer.set_text_content("x".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
let elem_id = "space-hold".to_string();
composer.textarea.insert_named_element(" ", elem_id.clone());
composer.voice_state.space_hold_started_at = Some(Instant::now());
composer.voice_state.space_hold_element_id = Some(elem_id);
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
composer.voice_state.key_release_supported = false;
composer.voice_state.space_hold_repeat_seen = true;
composer.process_space_hold_trigger();
assert_eq!("x ", composer.textarea.text());
assert!(composer.voice_state.space_hold_started_at.is_none());
assert!(!composer.voice_state.space_hold_repeat_seen);
if composer.is_recording() {
let _ = composer.stop_recording_and_start_transcription();
}
}
#[cfg(not(target_os = "linux"))]
#[test]
fn space_hold_timeout_with_repeat_does_not_duplicate_existing_space() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_voice_transcription_enabled(true);
composer.set_text_content("x ".to_string(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
let elem_id = "space-hold".to_string();
composer.textarea.insert_named_element(" ", elem_id.clone());
composer.voice_state.space_hold_started_at = Some(Instant::now());
composer.voice_state.space_hold_element_id = Some(elem_id);
composer.voice_state.space_hold_trigger = Some(Arc::new(AtomicBool::new(true)));
composer.voice_state.key_release_supported = false;
composer.voice_state.space_hold_repeat_seen = true;
composer.process_space_hold_trigger();
assert_eq!("x ", composer.textarea.text());
assert!(composer.voice_state.space_hold_started_at.is_none());
assert!(!composer.voice_state.space_hold_repeat_seen);
if composer.is_recording() {
let _ = composer.stop_recording_and_start_transcription();
}
}
#[cfg(not(target_os = "linux"))]
#[test]
fn replace_transcription_stops_spinner_for_placeholder() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let id = "voice-placeholder".to_string();
composer.textarea.insert_named_element("", id.clone());
let flag = Arc::new(AtomicBool::new(false));
composer
.spinner_stop_flags
.insert(id.clone(), Arc::clone(&flag));
composer.replace_transcription(&id, "transcribed text");
assert!(flag.load(Ordering::Relaxed));
assert!(!composer.spinner_stop_flags.contains_key(&id));
assert_eq!(composer.textarea.text(), "transcribed text");
}
#[cfg(not(target_os = "linux"))]
#[test]
fn set_text_content_stops_all_transcription_spinners() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
let flag_one = Arc::new(AtomicBool::new(false));
let flag_two = Arc::new(AtomicBool::new(false));
composer
.spinner_stop_flags
.insert("voice-1".to_string(), Arc::clone(&flag_one));
composer
.spinner_stop_flags
.insert("voice-2".to_string(), Arc::clone(&flag_two));
composer.set_text_content("draft".to_string(), Vec::new(), Vec::new());
assert!(flag_one.load(Ordering::Relaxed));
assert!(flag_two.load(Ordering::Relaxed));
assert!(composer.spinner_stop_flags.is_empty());
}
#[test]
fn extract_args_supports_quoted_paths_single_arg() {
let args = extract_positional_args_for_prompt_line(

View File

@@ -33,7 +33,6 @@ use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
@@ -132,7 +131,6 @@ pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
@@ -205,8 +203,8 @@ impl BottomPane {
placeholder_text,
disable_paste_burst,
);
composer.set_frame_requester(frame_requester.clone());
composer.set_skill_mentions(skills);
Self {
composer,
view_stack: Vec::new(),
@@ -292,11 +290,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) {
@@ -333,23 +326,8 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// Do not globally intercept space; only composer handles hold-to-talk.
// While recording, route all keys to the composer so it can stop on release or next key.
#[cfg(not(target_os = "linux"))]
if self.composer.is_recording() {
let (_ir, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
}
return InputResult::None;
}
// If a modal/view is active, handle it here; otherwise forward to composer.
if !self.view_stack.is_empty() {
if key_event.kind == KeyEventKind::Release {
return InputResult::None;
}
// We need three pieces of information after routing the key:
// whether Esc completed the view, whether the view finished for any
// reason, and whether a paste-burst timer should be scheduled.
@@ -453,7 +431,6 @@ impl BottomPane {
}
} else {
let needs_redraw = self.composer.handle_paste(pasted);
self.composer.sync_popups();
if needs_redraw {
self.request_redraw();
}
@@ -462,18 +439,9 @@ impl BottomPane {
pub(crate) fn insert_str(&mut self, text: &str) {
self.composer.insert_str(text);
self.composer.sync_popups();
self.request_redraw();
}
// Space hold timeout is handled inside ChatComposer via an internal timer.
pub(crate) fn pre_draw_tick(&mut self) {
// Allow composer to process any time-based transitions before drawing
#[cfg(not(target_os = "linux"))]
self.composer.process_space_hold_trigger();
self.composer.sync_popups();
}
/// Replace the composer text with `text`.
///
/// This is intended for fresh input where mention linkage does not need to
@@ -581,16 +549,10 @@ impl BottomPane {
/// Update the status indicator header (defaults to "Working") and details below it.
///
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
pub(crate) fn update_status(
&mut self,
header: String,
details: Option<String>,
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) {
pub(crate) fn update_status(&mut self, header: String, details: Option<String>) {
if let Some(status) = self.status.as_mut() {
status.update_header(header);
status.update_details(details, details_capitalization, details_max_lines.max(1));
status.update_details(details);
self.request_redraw();
}
}
@@ -926,7 +888,6 @@ impl BottomPane {
.on_history_entry_response(log_id, offset, entry);
if updated {
self.composer.sync_popups();
self.request_redraw();
}
}
@@ -1005,30 +966,6 @@ impl BottomPane {
}
}
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
self.composer.replace_transcription(id, text);
self.composer.sync_popups();
self.request_redraw();
}
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_transcription_in_place(id, text);
if updated {
self.composer.sync_popups();
self.request_redraw();
}
updated
}
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
self.composer.remove_transcription_placeholder(id);
self.composer.sync_popups();
self.request_redraw();
}
}
impl Renderable for BottomPane {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
@@ -1045,11 +982,8 @@ impl Renderable for BottomPane {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use ratatui::buffer::Buffer;
@@ -1341,8 +1275,6 @@ mod tests {
pane.update_status(
"Working".to_string(),
Some("First detail line\nSecond detail line".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]);
@@ -1628,58 +1560,4 @@ mod tests {
assert_eq!(on_ctrl_c_calls.get(), 0);
assert_eq!(handle_calls.get(), 1);
}
#[test]
fn release_events_are_ignored_for_active_view() {
#[derive(Default)]
struct CountingView {
handle_calls: Rc<Cell<usize>>,
}
impl Renderable for CountingView {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl BottomPaneView for CountingView {
fn handle_key_event(&mut self, _key_event: KeyEvent) {
self.handle_calls
.set(self.handle_calls.get().saturating_add(1));
}
}
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
let handle_calls = Rc::new(Cell::new(0));
pane.push_view(Box::new(CountingView {
handle_calls: Rc::clone(&handle_calls),
}));
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Down,
KeyModifiers::NONE,
KeyEventKind::Press,
));
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Down,
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert_eq!(handle_calls.get(), 1);
}
}

View File

@@ -3,7 +3,6 @@ use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -28,7 +27,6 @@ fn is_word_separator(ch: char) -> bool {
struct TextElement {
id: u64,
range: Range<usize>,
name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -103,7 +101,6 @@ impl TextArea {
self.elements.push(TextElement {
id,
range: start..end,
name: None,
});
}
self.elements.sort_by_key(|e| e.range.start);
@@ -259,11 +256,6 @@ impl TextArea {
}
pub fn input(&mut self, event: KeyEvent) {
// Only process key presses or repeats; ignore releases to avoid inserting
// characters on key-up events when modifiers are no longer reported.
if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return;
}
match event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
@@ -894,73 +886,6 @@ impl TextArea {
id
}
#[cfg(not(target_os = "linux"))]
pub fn insert_named_element(&mut self, text: &str, id: String) {
let start = self.clamp_pos_for_insertion(self.cursor_pos);
self.insert_str_at(start, text);
let end = start + text.len();
self.add_element_with_id(start..end, Some(id));
// Place cursor at end of inserted element
self.set_cursor(end);
}
pub fn replace_element_by_id(&mut self, id: &str, text: &str) -> bool {
if let Some(idx) = self
.elements
.iter()
.position(|e| e.name.as_deref() == Some(id))
{
let range = self.elements[idx].range.clone();
self.replace_range_raw(range, text);
self.elements.retain(|e| e.name.as_deref() != Some(id));
true
} else {
false
}
}
/// Update the element's text in place, preserving its id so callers can
/// update it again later (e.g. recording -> transcribing -> final).
#[allow(dead_code)]
pub fn update_named_element_by_id(&mut self, id: &str, text: &str) -> bool {
if let Some(elem_idx) = self
.elements
.iter()
.position(|e| e.name.as_deref() == Some(id))
{
let old_range = self.elements[elem_idx].range.clone();
let start = old_range.start;
self.replace_range_raw(old_range, text);
// After replace_range_raw, the old element entry was removed if fully overlapped.
// Re-add an updated element with the same id and new range.
let new_end = start + text.len();
self.add_element_with_id(start..new_end, Some(id.to_string()));
true
} else {
false
}
}
#[allow(dead_code)]
pub fn named_element_range(&self, id: &str) -> Option<std::ops::Range<usize>> {
self.elements
.iter()
.find(|e| e.name.as_deref() == Some(id))
.map(|e| e.range.clone())
}
fn add_element_with_id(&mut self, range: Range<usize>, name: Option<String>) -> u64 {
let id = self.next_element_id();
let elem = TextElement { id, range, name };
self.elements.push(elem);
self.elements.sort_by_key(|e| e.range.start);
id
}
fn add_element(&mut self, range: Range<usize>) -> u64 {
self.add_element_with_id(range, None)
}
/// Mark an existing text range as an atomic element without changing the text.
///
/// This is used to convert already-typed tokens (like `/plan`) into elements
@@ -985,7 +910,12 @@ impl TextArea {
{
return None;
}
let id = self.add_element(start..end);
let id = self.next_element_id();
self.elements.push(TextElement {
id,
range: start..end,
});
self.elements.sort_by_key(|e| e.range.start);
Some(id)
}
@@ -1001,11 +931,20 @@ impl TextArea {
len_before != self.elements.len()
}
fn add_element(&mut self, range: Range<usize>) -> u64 {
let id = self.next_element_id();
let elem = TextElement { id, range };
self.elements.push(elem);
self.elements.sort_by_key(|e| e.range.start);
id
}
fn next_element_id(&mut self) -> u64 {
let id = self.next_element_id;
self.next_element_id = self.next_element_id.saturating_add(1);
id
}
fn find_element_containing(&self, pos: usize) -> Option<usize> {
self.elements
.iter()

View File

@@ -243,8 +243,6 @@ use crate::render::renderable::RenderableExt;
use crate::render::renderable::RenderableItem;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
mod interrupts;
@@ -913,27 +911,15 @@ impl ChatWidget {
/// Update the status indicator header and details.
///
/// Passing `None` clears any existing details.
fn set_status(
&mut self,
header: String,
details: Option<String>,
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) {
fn set_status(&mut self, header: String, details: Option<String>) {
self.current_status_header = header.clone();
self.bottom_pane
.update_status(header, details, details_capitalization, details_max_lines);
self.bottom_pane.update_status(header, details);
}
/// Convenience wrapper around [`Self::set_status`];
/// updates the status indicator header and clears any existing details.
fn set_status_header(&mut self, header: String) {
self.set_status(
header,
None,
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
self.set_status(header, None);
}
/// Sets the currently rendered footer status-line value.
@@ -1969,16 +1955,15 @@ impl ChatWidget {
.map(|process| process.command_display.clone());
if ev.stdin.is_empty() {
// Empty stdin means we are polling for background output.
// Surface this in the status indicator (single "waiting" surface) instead of
// the transcript. Keep the header short so the interrupt hint remains visible.
// Surface this in the status header (single "waiting" surface) instead of the transcript.
self.bottom_pane.ensure_status_indicator();
self.bottom_pane.set_interrupt_hint_visible(true);
self.set_status(
"Waiting for background terminal".to_string(),
command_display.clone(),
StatusDetailsCapitalization::Preserve,
1,
);
let header = if let Some(command) = &command_display {
format!("Waiting for background terminal · {command}")
} else {
"Waiting for background terminal".to_string()
};
self.set_status_header(header);
match &mut self.unified_exec_wait_streak {
Some(wait) if wait.process_id == ev.process_id => {
wait.update_command_display(command_display);
@@ -2251,16 +2236,7 @@ impl ChatWidget {
self.retry_status_header = Some(self.current_status_header.clone());
}
self.bottom_pane.ensure_status_indicator();
self.set_status(
message,
additional_details,
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
}
pub(crate) fn pre_draw_tick(&mut self) {
self.bottom_pane.pre_draw_tick();
self.set_status(message, additional_details);
}
/// Handle completion of an `AgentMessage` turn item.
@@ -2852,9 +2828,6 @@ impl ChatWidget {
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget.bottom_pane.set_voice_transcription_enabled(
widget.config.features.enabled(Feature::VoiceTranscription),
);
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3023,9 +2996,6 @@ impl ChatWidget {
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget.bottom_pane.set_voice_transcription_enabled(
widget.config.features.enabled(Feature::VoiceTranscription),
);
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3183,9 +3153,6 @@ impl ChatWidget {
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
widget.bottom_pane.set_voice_transcription_enabled(
widget.config.features.enabled(Feature::VoiceTranscription),
);
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3307,14 +3274,7 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_mention_bindings(),
};
// Steer submissions during active final-answer streaming can race with turn
// completion and strand the UI in a running state. Queue those inputs instead
// of injecting immediately; `on_task_complete()` drains this FIFO via
// `maybe_send_next_queued_input()`, so no typed prompt is dropped.
let should_submit_now = self.is_session_configured()
&& !self.is_plan_streaming_in_tui()
&& self.stream_controller.is_none();
if should_submit_now {
if self.is_session_configured() && !self.is_plan_streaming_in_tui() {
// Submitted is only emitted when steer is enabled.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
@@ -6326,8 +6286,6 @@ impl ChatWidget {
self.set_status(
"Setting up sandbox...".to_string(),
Some("Hang tight, this may take a few minutes".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
self.request_redraw();
}
@@ -6383,9 +6341,6 @@ impl ChatWidget {
if feature == Feature::Steer {
self.bottom_pane.set_steer_enabled(enabled);
}
if feature == Feature::VoiceTranscription {
self.bottom_pane.set_voice_transcription_enabled(enabled);
}
if feature == Feature::Personality {
self.sync_personality_command_enabled();
}
@@ -7537,29 +7492,6 @@ impl ChatWidget {
}
}
#[cfg(not(target_os = "linux"))]
impl ChatWidget {
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
self.bottom_pane.replace_transcription(id, text);
// Ensure the UI redraws to reflect the updated transcription.
self.request_redraw();
}
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.bottom_pane.update_transcription_in_place(id, text);
if updated {
self.request_redraw();
}
updated
}
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
self.bottom_pane.remove_transcription_placeholder(id);
// Ensure the UI redraws to reflect placeholder removal.
self.request_redraw();
}
}
fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
summary.responses_api_overhead_ms > 0
|| summary.responses_api_inference_time_ms > 0

View File

@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
• Waiting for background terminal (0s • esc to …
└ cargo test -p codex-core -- --exact…
Ask Codex to do anything
? for shortcuts 100% context left

View File

@@ -3402,105 +3402,6 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn steer_enter_queues_while_final_answer_stream_is_active() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
// Keep the assistant stream open (no commit tick/finalize) to model the repro window:
// user presses Enter while the final answer is still streaming.
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.bottom_pane.set_composer_text(
"queued while streaming".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"queued while streaming"
);
assert_no_submit_op(&mut op_rx);
// Once final output ends, the queued input must be submitted automatically.
chat.on_task_complete(None, false);
assert!(chat.queued_user_messages.is_empty());
match next_submit_op(&mut op_rx) {
Op::UserTurn { .. } => {}
other => panic!("expected Op::UserTurn after stream completion, got {other:?}"),
}
}
#[tokio::test]
async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
// Simulate "dead mode" repro timing by keeping a final-answer stream active while the
// user submits multiple follow-up prompts.
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.bottom_pane
.set_composer_text("first follow-up".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.bottom_pane
.set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.queued_user_messages.len(), 2);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"first follow-up"
);
assert_eq!(
chat.queued_user_messages.back().unwrap().text,
"second follow-up"
);
assert_no_submit_op(&mut op_rx);
// Completion must recover by submitting the oldest queued prompt first.
chat.on_task_complete(None, false);
let first_items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(
first_items,
vec![UserInput::Text {
text: "first follow-up".to_string(),
text_elements: Vec::new(),
}]
);
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().text,
"second follow-up"
);
// A subsequent turn lifecycle should continue draining remaining queued prompts, proving
// the widget did not enter a permanently stuck state.
chat.on_task_started();
chat.on_task_complete(None, false);
let second_items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(
second_items,
vec![UserInput::Text {
text: "second follow-up".to_string(),
text_elements: Vec::new(),
}]
);
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn steer_enter_submits_when_plan_stream_is_not_active() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
@@ -4002,14 +3903,8 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
assert!(chat.active_cell.is_none());
assert_eq!(
chat.current_status_header,
"Waiting for background terminal"
"Waiting for background terminal · sleep 5"
);
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), "Waiting for background terminal");
assert_eq!(status.details(), Some("sleep 5"));
}
#[tokio::test]
@@ -4022,14 +3917,8 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
terminal_interaction(&mut chat, "call-wait-1b", "proc-1", "");
assert_eq!(
chat.current_status_header,
"Waiting for background terminal"
"Waiting for background terminal · just fix"
);
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), "Waiting for background terminal");
assert_eq!(status.details(), Some("just fix"));
chat.handle_codex_event(Event {
id: "turn-wait-1".into(),
@@ -4047,26 +3936,6 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
assert_snapshot!("unified_exec_waiting_multiple_empty_after", combined);
}
#[tokio::test]
async fn unified_exec_wait_status_renders_command_in_single_details_row_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
begin_unified_exec_startup(
&mut chat,
"call-wait-ui",
"proc-ui",
"cargo test -p codex-core -- --exact some::very::long::test::name",
);
terminal_interaction(&mut chat, "call-wait-ui-stdin", "proc-ui", "");
let rendered = render_bottom_popup(&chat, 48);
assert_snapshot!(
"unified_exec_wait_status_renders_command_in_single_details_row",
rendered
);
}
#[tokio::test]
async fn unified_exec_empty_then_non_empty_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -4094,14 +3963,8 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
terminal_interaction(&mut chat, "call-wait-3b", "proc-3", "");
assert_eq!(
chat.current_status_header,
"Waiting for background terminal"
"Waiting for background terminal · just fix"
);
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), "Waiting for background terminal");
assert_eq!(status.details(), Some("just fix"));
let pre_cells = drain_insert_history(&mut rx);
let active_combined = pre_cells
.iter()

View File

@@ -114,79 +114,7 @@ pub mod update_action;
mod update_prompt;
mod updates;
mod version;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
mod voice;
#[cfg(all(not(target_os = "linux"), not(feature = "voice-input")))]
mod voice {
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
pub struct RecordedAudio {
pub data: Vec<i16>,
pub sample_rate: u32,
pub channels: u16,
}
pub struct VoiceCapture;
pub(crate) struct RecordingMeterState;
impl VoiceCapture {
pub fn start() -> 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 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))
}
}
impl RecordingMeterState {
pub(crate) fn new() -> Self {
Self
}
pub(crate) fn next_text(&mut self, _peak: u16) -> String {
"⠤⠤⠤⠤".to_string()
}
}
pub fn transcribe_async(
id: String,
_audio: RecordedAudio,
_context: Option<String>,
tx: AppEventSender,
) {
tx.send(AppEvent::TranscriptionFailed {
id,
error: "voice input is unavailable in this build".to_string(),
});
}
}
mod wrapping;
#[cfg(test)]

View File

@@ -31,21 +31,14 @@ use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3;
const DETAILS_MAX_LINES: usize = 3;
const DETAILS_PREFIX: &str = "";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusDetailsCapitalization {
CapitalizeFirst,
Preserve,
}
/// Displays a single-line in-progress status with optional wrapped details.
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
header: String,
details: Option<String>,
details_max_lines: usize,
/// Optional suffix rendered after the elapsed/interrupt segment.
inline_message: Option<String>,
show_interrupt_hint: bool,
@@ -84,7 +77,6 @@ impl StatusIndicatorWidget {
Self {
header: String::from("Working"),
details: None,
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
inline_message: None,
show_interrupt_hint: true,
elapsed_running: Duration::ZERO,
@@ -107,22 +99,10 @@ impl StatusIndicatorWidget {
}
/// Update the details text shown below the header.
pub(crate) fn update_details(
&mut self,
details: Option<String>,
capitalization: StatusDetailsCapitalization,
max_lines: usize,
) {
self.details_max_lines = max_lines.max(1);
pub(crate) fn update_details(&mut self, details: Option<String>) {
self.details = details
.filter(|details| !details.is_empty())
.map(|details| {
let trimmed = details.trim_start();
match capitalization {
StatusDetailsCapitalization::CapitalizeFirst => capitalize_first(trimmed),
StatusDetailsCapitalization::Preserve => trimmed.to_string(),
}
});
.map(|details| capitalize_first(details.trim_start()));
}
/// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`.
@@ -213,8 +193,8 @@ impl StatusIndicatorWidget {
let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts);
if out.len() > self.details_max_lines {
out.truncate(self.details_max_lines);
if out.len() > DETAILS_MAX_LINES {
out.truncate(DETAILS_MAX_LINES);
let content_width = usize::from(width).saturating_sub(prefix_width).max(1);
let max_base_len = content_width.saturating_sub(1);
if let Some(last) = out.last_mut()
@@ -349,11 +329,7 @@ mod tests {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
w.update_details(
Some("A man a plan a canal panama".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
w.update_details(Some("A man a plan a canal panama".to_string()));
w.set_interrupt_hint_visible(false);
// Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable.
@@ -396,45 +372,14 @@ mod tests {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
w.update_details(
Some("abcd abcd abcd abcd".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
w.update_details(Some("abcd abcd abcd abcd".to_string()));
let lines = w.wrapped_details_lines(6);
assert_eq!(lines.len(), STATUS_DETAILS_DEFAULT_MAX_LINES);
assert_eq!(lines.len(), DETAILS_MAX_LINES);
let last = lines.last().expect("expected last details line");
assert!(
last.spans[1].content.as_ref().ends_with(""),
"expected ellipsis in last line: {last:?}"
);
}
#[test]
fn details_args_can_disable_capitalization_and_limit_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
w.update_details(
Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()),
StatusDetailsCapitalization::Preserve,
1,
);
assert_eq!(
w.details(),
Some("cargo test -p codex-core and then cargo test -p codex-tui")
);
let lines = w.wrapped_details_lines(24);
assert_eq!(lines.len(), 1);
let last = lines.last().expect("expected one details line");
assert!(
last.spans
.last()
.is_some_and(|span| span.content.as_ref().contains('…')),
"expected one-line details to be ellipsized, got {last:?}"
);
}
}

View File

@@ -1,517 +0,0 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core::default_client::get_codex_user_agent;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use cpal::traits::StreamTrait;
use hound::SampleFormat;
use hound::WavSpec;
use hound::WavWriter;
use std::collections::VecDeque;
use std::io::Cursor;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
use std::sync::atomic::Ordering;
use tracing::error;
use tracing::info;
use tracing::trace;
struct TranscriptionAuthContext {
mode: AuthMode,
bearer_token: String,
chatgpt_account_id: Option<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_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 stop(mut self) -> Result<RecordedAudio, String> {
// 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()
}
}
pub(crate) struct RecordingMeterState {
history: VecDeque<char>,
noise_ema: f64,
env: f64,
}
impl RecordingMeterState {
pub(crate) fn new() -> Self {
let mut history = VecDeque::with_capacity(4);
while history.len() < 4 {
history.push_back('⠤');
}
Self {
history,
noise_ema: 0.02,
env: 0.0,
}
}
pub(crate) fn next_text(&mut self, peak: u16) -> String {
const SYMBOLS: [char; 7] = ['⠤', '⠴', '⠶', '⠷', '⡷', '⡿', '⣿'];
const ALPHA_NOISE: f64 = 0.05;
const ATTACK: f64 = 0.80;
const RELEASE: f64 = 0.25;
let latest_peak = peak as f64 / (i16::MAX as f64);
if latest_peak > self.env {
self.env = ATTACK * latest_peak + (1.0 - ATTACK) * self.env;
} else {
self.env = RELEASE * latest_peak + (1.0 - RELEASE) * self.env;
}
let rms_approx = self.env * 0.7;
self.noise_ema = (1.0 - ALPHA_NOISE) * self.noise_ema + ALPHA_NOISE * rms_approx;
let ref_level = self.noise_ema.max(0.01);
let fast_signal = 0.8 * latest_peak + 0.2 * self.env;
let target = 2.0f64;
let raw = (fast_signal / (ref_level * target)).max(0.0);
let k = 1.6f64;
let compressed = (raw.ln_1p() / k.ln_1p()).min(1.0);
let idx = (compressed * (SYMBOLS.len() as f64 - 1.0))
.round()
.clamp(0.0, SYMBOLS.len() as f64 - 1.0) as usize;
let level_char = SYMBOLS[idx];
if self.history.len() >= 4 {
self.history.pop_front();
}
self.history.push_back(level_char);
let mut text = String::with_capacity(4);
for ch in &self.history {
text.push(*ch);
}
text
}
}
pub fn transcribe_async(
id: String,
audio: RecordedAudio,
context: Option<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_input_device_and_config() -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let device = host
.default_input_device()
.ok_or_else(|| "no input audio device available".to_string())?;
let config = device
.default_input_config()
.map_err(|e| format!("failed to get default input config: {e}"))?;
Ok((device, config))
}
fn build_input_stream(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
data: Arc<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()),
}
}
#[inline]
fn f32_abs_to_u16(x: f32) -> u16 {
let peak_u = (x.abs().min(1.0) * i16::MAX as f32) as i32;
peak_u.max(0) as u16
}
#[inline]
fn f32_to_i16(s: f32) -> i16 {
(s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16
}
fn peak_f32(input: &[f32]) -> u16 {
let mut peak: f32 = 0.0;
for &s in input {
let a = s.abs();
if a > peak {
peak = a;
}
}
f32_abs_to_u16(peak)
}
fn peak_i16(input: &[i16]) -> u16 {
let mut peak: i32 = 0;
for &s in input {
let a = (s as i32).unsigned_abs() as i32;
if a > peak {
peak = a;
}
}
peak as u16
}
fn convert_u16_to_i16_and_peak(input: &[u16], out: &mut Vec<i16>) -> u16 {
let mut peak: i32 = 0;
for &s in input {
let v_i16 = (s as i32 - 32768) as i16;
let a = (v_i16 as i32).unsigned_abs() as i32;
if a > peak {
peak = a;
}
out.push(v_i16);
}
peak as u16
}
// -------------------------
// Transcription helpers
// -------------------------
fn clip_duration_seconds(audio: &RecordedAudio) -> f32 {
let total_samples = audio.data.len() as f32;
let samples_per_second = (audio.sample_rate as f32) * (audio.channels as f32);
if samples_per_second > 0.0 {
total_samples / samples_per_second
} else {
0.0
}
}
fn encode_wav_normalized(audio: &RecordedAudio) -> Result<Vec<u8>, String> {
let mut wav_bytes: Vec<u8> = Vec::new();
let spec = WavSpec {
channels: audio.channels,
sample_rate: audio.sample_rate,
bits_per_sample: 16,
sample_format: SampleFormat::Int,
};
let mut cursor = Cursor::new(&mut wav_bytes);
let mut writer =
WavWriter::new(&mut cursor, spec).map_err(|_| "failed to create wav writer".to_string())?;
// Simple peak normalization with headroom to improve audibility on quiet inputs.
let segment = &audio.data[..];
let mut peak: i16 = 0;
for &s in segment {
let a = s.unsigned_abs();
if a > peak.unsigned_abs() {
peak = s;
}
}
let peak_abs = (peak as i32).unsigned_abs() as i32;
let target = (i16::MAX as f32) * 0.9; // leave some headroom
let gain: f32 = if peak_abs > 0 {
target / (peak_abs as f32)
} else {
1.0
};
for &s in segment {
let v = ((s as f32) * gain)
.round()
.clamp(i16::MIN as f32, i16::MAX as f32) as i16;
writer
.write_sample(v)
.map_err(|_| "failed writing wav sample".to_string())?;
}
writer
.finalize()
.map_err(|_| "failed to finalize wav".to_string())?;
Ok(wav_bytes)
}
fn normalize_chatgpt_base_url(input: &str) -> String {
let mut base_url = input.to_string();
while base_url.ends_with('/') {
base_url.pop();
}
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
base_url
}
async fn resolve_auth() -> Result<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 = reqwest::Client::new();
let audio_bytes = wav_bytes.len();
let prompt_for_log = context.as_deref().unwrap_or("").to_string();
let (endpoint, request) =
if matches!(auth.mode, AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) {
let part = reqwest::multipart::Part::bytes(wav_bytes)
.file_name("audio.wav")
.mime_str("audio/wav")
.map_err(|e| format!("failed to set mime: {e}"))?;
let form = reqwest::multipart::Form::new().part("file", part);
let endpoint = format!("{}/transcribe", auth.chatgpt_base_url);
let mut req = client
.post(&endpoint)
.bearer_auth(&auth.bearer_token)
.multipart(form)
.header("User-Agent", get_codex_user_agent());
if let Some(acc) = auth.chatgpt_account_id {
req = req.header("ChatGPT-Account-Id", acc);
}
(endpoint, req)
} else {
let part = reqwest::multipart::Part::bytes(wav_bytes)
.file_name("audio.wav")
.mime_str("audio/wav")
.map_err(|e| format!("failed to set mime: {e}"))?;
let mut form = reqwest::multipart::Form::new()
.text("model", "gpt-4o-transcribe")
.part("file", part);
if let Some(context) = context {
form = form.text("prompt", context);
}
let endpoint = "https://api.openai.com/v1/audio/transcriptions".to_string();
(
endpoint,
client
.post("https://api.openai.com/v1/audio/transcriptions")
.bearer_auth(&auth.bearer_token)
.multipart(form)
.header("User-Agent", get_codex_user_agent()),
)
};
let audio_kib = audio_bytes as f32 / 1024.0;
let mode = auth.mode;
trace!(
"sending transcription request: mode={mode:?} endpoint={endpoint} duration={duration_seconds:.2}s audio={audio_kib:.1}KiB prompt={prompt_for_log}"
);
let resp = request
.send()
.await
.map_err(|e| format!("transcription request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|_| "<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)
}
}

View File

@@ -71,3 +71,15 @@ index 6ffc9f7..e02089a 100644
],
}),
)
diff --git a/toolchain/llvm/llvm.bzl b/toolchain/llvm/llvm.bzl
index d068085..c152552 100644
--- a/toolchain/llvm/llvm.bzl
+++ b/toolchain/llvm/llvm.bzl
@@ -7,6 +7,7 @@ def declare_llvm_targets(*, suffix = ""):
name = "builtin_headers",
# Grab whichever version-specific dir is there.
path = native.glob(["lib/clang/*"], exclude_directories = 0)[0] + "/include",
+ visibility = ["//visibility:public"],
)
# Convenient exports