mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Remove TUI realtime voice support (#27801)
## Why Removes the realtime audio support from TUI. ## What Changed - Removed the TUI `/realtime` and realtime `/settings` command paths. - Deleted TUI voice capture/playback, WebRTC session handling, audio-device selection UI, and recording-meter code. - Removed TUI realtime tests and snapshots that covered the deleted surfaces. - Dropped the TUI-only `cpal` and `codex-realtime-webrtc` dependencies and refreshed the Rust/Bazel locks.
This commit is contained in:
committed by
GitHub
Unverified
parent
8f2d6416ce
commit
576f603440
Generated
-17
File diff suppressed because one or more lines are too long
Generated
-190
@@ -291,28 +291,6 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -4004,7 +3982,6 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-realtime-webrtc",
|
||||
"codex-rollout",
|
||||
"codex-sandboxing",
|
||||
"codex-shell-command",
|
||||
@@ -4026,7 +4003,6 @@ dependencies = [
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
"core_test_support",
|
||||
"cpal",
|
||||
"crossterm",
|
||||
"derive_more 2.1.1",
|
||||
"diffy",
|
||||
@@ -4646,49 +4622,6 @@ dependencies = [
|
||||
"zstd 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -5136,12 +5069,6 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
@@ -8963,15 +8890,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
@@ -9167,35 +9085,12 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"jni-sys 0.3.0",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nibble_vec"
|
||||
version = "0.1.0"
|
||||
@@ -9368,17 +9263,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -9429,28 +9313,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
@@ -9660,29 +9522,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -14812,16 +14651,6 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -14853,16 +14682,6 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -14971,15 +14790,6 @@ dependencies = [
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
|
||||
@@ -208,7 +208,6 @@ codex-plugin = { path = "plugin" }
|
||||
codex-model-provider = { path = "model-provider" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-realtime-webrtc = { path = "realtime-webrtc" }
|
||||
codex-prompts = { path = "prompts" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-response-debug-context = { path = "response-debug-context" }
|
||||
|
||||
@@ -49,7 +49,6 @@ codex-models-manager = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-realtime-webrtc = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
@@ -125,9 +124,6 @@ uuid = { workspace = true }
|
||||
codex-windows-sandbox = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["time"] }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
cpal = "0.15"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ use crate::app_event::HistoryLookupResponse;
|
||||
use crate::app_event::PermissionProfileSelection;
|
||||
use crate::app_event::PluginLocation;
|
||||
use crate::app_event::RateLimitRefreshOrigin;
|
||||
use crate::app_event::RealtimeAudioDeviceKind;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
@@ -798,18 +798,6 @@ impl App {
|
||||
self.sync_active_thread_personality_setting(app_server, personality)
|
||||
.await;
|
||||
}
|
||||
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
|
||||
self.chat_widget.open_realtime_audio_device_selection(kind);
|
||||
}
|
||||
AppEvent::RealtimeWebrtcOfferCreated { result } => {
|
||||
self.chat_widget.on_realtime_webrtc_offer_created(result);
|
||||
}
|
||||
AppEvent::RealtimeWebrtcEvent(event) => {
|
||||
self.chat_widget.on_realtime_webrtc_event(event);
|
||||
}
|
||||
AppEvent::RealtimeWebrtcLocalAudioLevel(peak) => {
|
||||
self.chat_widget.on_realtime_webrtc_local_audio_level(peak);
|
||||
}
|
||||
AppEvent::OpenReasoningPopup { model } => {
|
||||
self.chat_widget.open_reasoning_popup(model);
|
||||
}
|
||||
@@ -1442,56 +1430,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
|
||||
let builder = match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => {
|
||||
ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_realtime_microphone(name.as_deref())
|
||||
}
|
||||
RealtimeAudioDeviceKind::Speaker => {
|
||||
ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_realtime_speaker(name.as_deref())
|
||||
}
|
||||
};
|
||||
|
||||
match builder.apply().await {
|
||||
Ok(()) => {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => {
|
||||
self.config.realtime_audio.microphone = name.clone();
|
||||
}
|
||||
RealtimeAudioDeviceKind::Speaker => {
|
||||
self.config.realtime_audio.speaker = name.clone();
|
||||
}
|
||||
}
|
||||
self.chat_widget
|
||||
.set_realtime_audio_device(kind, name.clone());
|
||||
|
||||
if self.chat_widget.realtime_conversation_is_live() {
|
||||
self.chat_widget.open_realtime_audio_restart_prompt(kind);
|
||||
} else {
|
||||
let selection = name.unwrap_or_else(|| "System default".to_string());
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Realtime {} set to {selection}", kind.noun()),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist realtime audio selection"
|
||||
);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save realtime {}: {err}",
|
||||
kind.noun()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::RestartRealtimeAudioDevice { kind } => {
|
||||
self.chat_widget.restart_realtime_audio_device(kind);
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
let mut config = self.config.clone();
|
||||
if !self.try_set_approval_policy_on_config(
|
||||
@@ -1943,18 +1881,6 @@ impl App {
|
||||
));
|
||||
}
|
||||
},
|
||||
#[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_recording_meter_in_place(&id, &text);
|
||||
if updated
|
||||
|| self
|
||||
.chat_widget
|
||||
.stop_realtime_conversation_for_deleted_meter(&id)
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::StatusLineSetup {
|
||||
items,
|
||||
use_theme_colors,
|
||||
|
||||
@@ -696,22 +696,6 @@ impl App {
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::RealtimeConversationStart { transport, voice } => {
|
||||
app_server
|
||||
.thread_realtime_start(thread_id, transport.clone(), voice.clone())
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::RealtimeConversationAudio(frame) => {
|
||||
app_server
|
||||
.thread_realtime_audio(thread_id, frame.clone())
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::RealtimeConversationClose => {
|
||||
app_server.thread_realtime_stop(thread_id).await?;
|
||||
Ok(true)
|
||||
}
|
||||
AppCommand::RunUserShellCommand { command } => {
|
||||
app_server
|
||||
.thread_shell_command(thread_id, command.to_string())
|
||||
|
||||
@@ -6,8 +6,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
@@ -30,12 +28,6 @@ pub(crate) enum AppCommand {
|
||||
behavior: InterruptBehavior,
|
||||
},
|
||||
CleanBackgroundTerminals,
|
||||
RealtimeConversationStart {
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
voice: Option<Value>,
|
||||
},
|
||||
RealtimeConversationAudio(ThreadRealtimeAudioChunk),
|
||||
RealtimeConversationClose,
|
||||
RunUserShellCommand {
|
||||
command: String,
|
||||
},
|
||||
@@ -135,22 +127,6 @@ impl AppCommand {
|
||||
Self::CleanBackgroundTerminals
|
||||
}
|
||||
|
||||
pub(crate) fn realtime_conversation_start(
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
voice: Option<Value>,
|
||||
) -> Self {
|
||||
Self::RealtimeConversationStart { transport, voice }
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
||||
pub(crate) fn realtime_conversation_audio(frame: ThreadRealtimeAudioChunk) -> Self {
|
||||
Self::RealtimeConversationAudio(frame)
|
||||
}
|
||||
|
||||
pub(crate) fn realtime_conversation_close() -> Self {
|
||||
Self::RealtimeConversationClose
|
||||
}
|
||||
|
||||
pub(crate) fn run_user_shell_command(command: String) -> Self {
|
||||
Self::RunUserShellCommand { command }
|
||||
}
|
||||
|
||||
@@ -46,17 +46,9 @@ use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::models::ActivePermissionProfile;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_realtime_webrtc::RealtimeWebrtcEvent;
|
||||
use codex_realtime_webrtc::RealtimeWebrtcSessionHandle;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RealtimeAudioDeviceKind {
|
||||
Microphone,
|
||||
Speaker,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ThreadGoalSetMode {
|
||||
ConfirmIfExists,
|
||||
@@ -74,22 +66,6 @@ pub(crate) struct HistoryLookupResponse {
|
||||
pub(crate) entry: Option<String>,
|
||||
}
|
||||
|
||||
impl RealtimeAudioDeviceKind {
|
||||
pub(crate) fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Microphone => "Microphone",
|
||||
Self::Speaker => "Speaker",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn noun(self) -> &'static str {
|
||||
match self {
|
||||
Self::Microphone => "microphone",
|
||||
Self::Speaker => "speaker",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ConsolidationScrollbackReflow {
|
||||
IfResizeReflowRan,
|
||||
@@ -672,34 +648,6 @@ pub(crate) enum AppEvent {
|
||||
service_tier: Option<String>,
|
||||
},
|
||||
|
||||
/// Open the device picker for a realtime microphone or speaker.
|
||||
OpenRealtimeAudioDeviceSelection {
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
},
|
||||
|
||||
/// Persist the selected realtime microphone or speaker to top-level config.
|
||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
||||
PersistRealtimeAudioDeviceSelection {
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Restart the selected realtime microphone or speaker locally.
|
||||
RestartRealtimeAudioDevice {
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
},
|
||||
|
||||
/// Result of creating a TUI-owned realtime WebRTC offer.
|
||||
RealtimeWebrtcOfferCreated {
|
||||
result: Result<RealtimeWebrtcOffer, String>,
|
||||
},
|
||||
|
||||
/// Peer-connection lifecycle event from a TUI-owned realtime WebRTC session.
|
||||
RealtimeWebrtcEvent(RealtimeWebrtcEvent),
|
||||
|
||||
/// Local microphone level from a TUI-owned realtime WebRTC session.
|
||||
RealtimeWebrtcLocalAudioLevel(u16),
|
||||
|
||||
/// Open the reasoning selection popup after picking a model.
|
||||
OpenReasoningPopup {
|
||||
model: ModelPreset,
|
||||
@@ -909,14 +857,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,
|
||||
},
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
@@ -1048,12 +988,6 @@ pub(crate) struct PermissionProfileSelection {
|
||||
pub display_label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RealtimeWebrtcOffer {
|
||||
pub(crate) offer_sdp: String,
|
||||
pub(crate) handle: RealtimeWebrtcSessionHandle,
|
||||
}
|
||||
|
||||
/// The exit strategy requested by the UI layer.
|
||||
///
|
||||
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
|
||||
|
||||
@@ -11,7 +11,6 @@ use codex_app_server_protocol::FileChangeApprovalDecision;
|
||||
use codex_app_server_protocol::McpServerElicitationAction;
|
||||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||||
use codex_app_server_protocol::ReviewTarget;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use codex_app_server_protocol::ToolRequestUserInputResponse;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
@@ -72,13 +71,6 @@ impl AppEventSender {
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "linux", allow(dead_code))]
|
||||
pub(crate) fn realtime_conversation_audio(&self, frame: ThreadRealtimeAudioChunk) {
|
||||
self.send(AppEvent::CodexOp(AppCommand::realtime_conversation_audio(
|
||||
frame,
|
||||
)));
|
||||
}
|
||||
|
||||
pub(crate) fn user_input_answer(&self, id: String, response: ToolRequestUserInputResponse) {
|
||||
self.send(AppEvent::CodexOp(AppCommand::user_input_answer(
|
||||
id, response,
|
||||
|
||||
@@ -81,14 +81,6 @@ use codex_app_server_protocol::ThreadMetadataUpdateParams;
|
||||
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
|
||||
use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
||||
use codex_app_server_protocol::ThreadRealtimeStopParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeStopResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadRollbackParams;
|
||||
@@ -1119,57 +1111,6 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_realtime_start(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
voice: Option<serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let params = thread_realtime_start_params(thread_id, transport, voice)?;
|
||||
let _: ThreadRealtimeStartResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::ThreadRealtimeStart { request_id, params })
|
||||
.await
|
||||
.wrap_err("thread/realtime/start failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_realtime_audio(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
frame: ThreadRealtimeAudioChunk,
|
||||
) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let _: ThreadRealtimeAppendAudioResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
|
||||
request_id,
|
||||
params: ThreadRealtimeAppendAudioParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
audio: frame,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/realtime/appendAudio failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
|
||||
let request_id = self.next_request_id();
|
||||
let _: ThreadRealtimeStopResponse = self
|
||||
.client
|
||||
.request_typed(ClientRequest::ThreadRealtimeStop {
|
||||
request_id,
|
||||
params: ThreadRealtimeStopParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/realtime/stop failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn reject_server_request(
|
||||
&self,
|
||||
request_id: RequestId,
|
||||
@@ -1222,34 +1163,6 @@ pub(crate) async fn start_thread_with_request_handle(
|
||||
started_thread_from_start_response(response, &config, thread_params_mode).await
|
||||
}
|
||||
|
||||
fn thread_realtime_start_params(
|
||||
thread_id: ThreadId,
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
voice: Option<serde_json::Value>,
|
||||
) -> Result<ThreadRealtimeStartParams> {
|
||||
let mut value = serde_json::Map::new();
|
||||
value.insert(
|
||||
"threadId".to_string(),
|
||||
serde_json::Value::String(thread_id.to_string()),
|
||||
);
|
||||
value.insert(
|
||||
"outputModality".to_string(),
|
||||
serde_json::Value::String("audio".to_string()),
|
||||
);
|
||||
if let Some(transport) = transport {
|
||||
value.insert(
|
||||
"transport".to_string(),
|
||||
serde_json::to_value(transport).wrap_err("serializing realtime transport")?,
|
||||
);
|
||||
}
|
||||
if let Some(voice) = voice {
|
||||
value.insert("voice".to_string(), voice);
|
||||
}
|
||||
|
||||
serde_json::from_value(serde_json::Value::Object(value))
|
||||
.wrap_err("mapping TUI realtime start params to app-server params")
|
||||
}
|
||||
|
||||
pub(crate) fn status_account_display_from_auth_mode(
|
||||
auth_mode: Option<AuthMode>,
|
||||
plan_type: Option<codex_protocol::account::PlanType>,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
use crate::legacy_core::config::Config;
|
||||
use cpal::traits::DeviceTrait;
|
||||
use cpal::traits::HostTrait;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::app_event::RealtimeAudioDeviceKind;
|
||||
|
||||
const PREFERRED_INPUT_SAMPLE_RATE: u32 = 24_000;
|
||||
const PREFERRED_INPUT_CHANNELS: u16 = 1;
|
||||
|
||||
pub(crate) fn list_realtime_audio_device_names(
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let host = cpal::default_host();
|
||||
let mut device_names = Vec::new();
|
||||
for device in devices(&host, kind)? {
|
||||
let Ok(name) = device.name() else {
|
||||
continue;
|
||||
};
|
||||
if !device_names.contains(&name) {
|
||||
device_names.push(name);
|
||||
}
|
||||
}
|
||||
Ok(device_names)
|
||||
}
|
||||
|
||||
pub(crate) fn select_configured_input_device_and_config(
|
||||
config: &Config,
|
||||
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
|
||||
select_device_and_config(RealtimeAudioDeviceKind::Microphone, config)
|
||||
}
|
||||
|
||||
pub(crate) fn select_configured_output_device_and_config(
|
||||
config: &Config,
|
||||
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
|
||||
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
|
||||
}
|
||||
|
||||
pub(crate) fn preferred_input_config(
|
||||
device: &cpal::Device,
|
||||
) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
let supported_configs = device
|
||||
.supported_input_configs()
|
||||
.map_err(|err| format!("failed to enumerate input audio configs: {err}"))?;
|
||||
|
||||
supported_configs
|
||||
.filter_map(|range| {
|
||||
let sample_format_rank = match range.sample_format() {
|
||||
cpal::SampleFormat::I16 => 0u8,
|
||||
cpal::SampleFormat::U16 => 1u8,
|
||||
cpal::SampleFormat::F32 => 2u8,
|
||||
_ => return None,
|
||||
};
|
||||
let sample_rate = preferred_input_sample_rate(&range);
|
||||
let sample_rate_penalty = sample_rate.0.abs_diff(PREFERRED_INPUT_SAMPLE_RATE);
|
||||
let channel_penalty = range.channels().abs_diff(PREFERRED_INPUT_CHANNELS);
|
||||
Some((
|
||||
(sample_rate_penalty, channel_penalty, sample_format_rank),
|
||||
range.with_sample_rate(sample_rate),
|
||||
))
|
||||
})
|
||||
.min_by_key(|(score, _)| *score)
|
||||
.map(|(_, config)| config)
|
||||
.or_else(|| device.default_input_config().ok())
|
||||
.ok_or_else(|| "failed to get default input config".to_string())
|
||||
}
|
||||
|
||||
fn select_device_and_config(
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
config: &Config,
|
||||
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
|
||||
let host = cpal::default_host();
|
||||
let configured_name = configured_name(kind, config);
|
||||
let selected = configured_name
|
||||
.and_then(|name| find_device_by_name(&host, kind, name))
|
||||
.or_else(|| {
|
||||
let default_device = default_device(&host, kind);
|
||||
if let Some(name) = configured_name && default_device.is_some() {
|
||||
warn!(
|
||||
"configured {} audio device `{name}` was unavailable; falling back to system default",
|
||||
kind.noun()
|
||||
);
|
||||
}
|
||||
default_device
|
||||
})
|
||||
.ok_or_else(|| missing_device_error(kind, configured_name))?;
|
||||
|
||||
let stream_config = match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => preferred_input_config(&selected)?,
|
||||
RealtimeAudioDeviceKind::Speaker => default_config(&selected, kind)?,
|
||||
};
|
||||
Ok((selected, stream_config))
|
||||
}
|
||||
|
||||
fn configured_name(kind: RealtimeAudioDeviceKind, config: &Config) -> Option<&str> {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => config.realtime_audio.microphone.as_deref(),
|
||||
RealtimeAudioDeviceKind::Speaker => config.realtime_audio.speaker.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_device_by_name(
|
||||
host: &cpal::Host,
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
name: &str,
|
||||
) -> Option<cpal::Device> {
|
||||
let devices = devices(host, kind).ok()?;
|
||||
devices
|
||||
.into_iter()
|
||||
.find(|device| device.name().ok().as_deref() == Some(name))
|
||||
}
|
||||
|
||||
fn devices(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => host
|
||||
.input_devices()
|
||||
.map(|devices| devices.collect())
|
||||
.map_err(|err| format!("failed to enumerate input audio devices: {err}")),
|
||||
RealtimeAudioDeviceKind::Speaker => host
|
||||
.output_devices()
|
||||
.map(|devices| devices.collect())
|
||||
.map_err(|err| format!("failed to enumerate output audio devices: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_device(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Option<cpal::Device> {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => host.default_input_device(),
|
||||
RealtimeAudioDeviceKind::Speaker => host.default_output_device(),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_config(
|
||||
device: &cpal::Device,
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
) -> Result<cpal::SupportedStreamConfig, String> {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => device
|
||||
.default_input_config()
|
||||
.map_err(|err| format!("failed to get default input config: {err}")),
|
||||
RealtimeAudioDeviceKind::Speaker => device
|
||||
.default_output_config()
|
||||
.map_err(|err| format!("failed to get default output config: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn preferred_input_sample_rate(range: &cpal::SupportedStreamConfigRange) -> cpal::SampleRate {
|
||||
let min = range.min_sample_rate().0;
|
||||
let max = range.max_sample_rate().0;
|
||||
if (min..=max).contains(&PREFERRED_INPUT_SAMPLE_RATE) {
|
||||
cpal::SampleRate(PREFERRED_INPUT_SAMPLE_RATE)
|
||||
} else if PREFERRED_INPUT_SAMPLE_RATE < min {
|
||||
cpal::SampleRate(min)
|
||||
} else {
|
||||
cpal::SampleRate(max)
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
|
||||
match (kind, configured_name) {
|
||||
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
|
||||
format!(
|
||||
"configured microphone `{name}` was unavailable and no default input audio device was found"
|
||||
)
|
||||
}
|
||||
(RealtimeAudioDeviceKind::Speaker, Some(name)) => {
|
||||
format!(
|
||||
"configured speaker `{name}` was unavailable and no default output audio device was found"
|
||||
)
|
||||
}
|
||||
(RealtimeAudioDeviceKind::Microphone, None) => {
|
||||
"no input audio device available".to_string()
|
||||
}
|
||||
(RealtimeAudioDeviceKind::Speaker, None) => "no output audio device available".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -358,9 +358,6 @@ pub(crate) struct ChatComposer {
|
||||
/// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can
|
||||
/// prepare their argument text without also double-recording the full command invocation.
|
||||
pending_slash_command_history: Option<HistoryEntry>,
|
||||
// Monotonically increasing identifier for textarea elements we insert.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
next_element_id: u64,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
plugins: Option<Vec<PluginCapabilitySummary>>,
|
||||
connectors_snapshot: Option<ConnectorsSnapshot>,
|
||||
@@ -373,8 +370,6 @@ pub(crate) struct ChatComposer {
|
||||
mentions_v2_enabled: bool,
|
||||
goal_command_enabled: bool,
|
||||
personality_command_enabled: bool,
|
||||
realtime_conversation_enabled: bool,
|
||||
audio_device_selection_enabled: bool,
|
||||
windows_degraded_sandbox_active: bool,
|
||||
side_conversation_active: bool,
|
||||
history_search: Option<HistorySearchSession>,
|
||||
@@ -441,8 +436,6 @@ impl ChatComposer {
|
||||
service_tier_commands_enabled: self.service_tier_commands_enabled,
|
||||
goal_command_enabled: self.goal_command_enabled,
|
||||
personality_command_enabled: self.personality_command_enabled,
|
||||
realtime_conversation_enabled: self.realtime_conversation_enabled,
|
||||
audio_device_selection_enabled: self.audio_device_selection_enabled,
|
||||
allow_elevate_sandbox: self.windows_degraded_sandbox_active,
|
||||
side_conversation_active: self.side_conversation_active,
|
||||
}
|
||||
@@ -527,8 +520,6 @@ impl ChatComposer {
|
||||
is_task_running: false,
|
||||
queue_submissions: false,
|
||||
pending_slash_command_history: None,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
next_element_id: 0,
|
||||
skills: None,
|
||||
plugins: None,
|
||||
connectors_snapshot: None,
|
||||
@@ -541,8 +532,6 @@ impl ChatComposer {
|
||||
mentions_v2_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
side_conversation_active: false,
|
||||
history_search: None,
|
||||
@@ -562,13 +551,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);
|
||||
}
|
||||
@@ -693,14 +675,6 @@ impl ChatComposer {
|
||||
self.personality_command_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
|
||||
self.realtime_conversation_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
|
||||
self.audio_device_selection_enabled = enabled;
|
||||
}
|
||||
|
||||
pub fn set_side_conversation_active(&mut self, active: bool) {
|
||||
self.side_conversation_active = active;
|
||||
}
|
||||
@@ -3957,23 +3931,6 @@ fn footer_insert_newline_key(
|
||||
.or_else(|| bindings.first().copied())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl ChatComposer {
|
||||
pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
self.draft.textarea.update_named_element_by_id(id, text)
|
||||
}
|
||||
|
||||
pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
|
||||
let id = self.next_id();
|
||||
self.draft.textarea.insert_named_element(text, id.clone());
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove_recording_meter_placeholder(&mut self, id: &str) {
|
||||
let _ = self.draft.textarea.replace_element_by_id(id, "");
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_description(skill: &SkillMetadata) -> Option<String> {
|
||||
let description = skill
|
||||
.interface
|
||||
@@ -4577,26 +4534,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn remove_recording_meter_placeholder_clears_placeholder_text() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
sender,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
|
||||
let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤");
|
||||
composer.remove_recording_meter_placeholder(&id);
|
||||
|
||||
assert_eq!(composer.draft.textarea.text(), "");
|
||||
assert!(composer.draft.textarea.named_element_range(&id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_flash_expires_and_falls_back_to_hint_override() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -177,8 +177,6 @@ impl<'a> SlashInput<'a> {
|
||||
service_tier_commands_enabled: self.command_flags.service_tier_commands_enabled,
|
||||
goal_command_enabled: self.command_flags.goal_command_enabled,
|
||||
personality_command_enabled: self.command_flags.personality_command_enabled,
|
||||
realtime_conversation_enabled: self.command_flags.realtime_conversation_enabled,
|
||||
audio_device_selection_enabled: self.command_flags.audio_device_selection_enabled,
|
||||
windows_degraded_sandbox_active: self.command_flags.allow_elevate_sandbox,
|
||||
side_conversation_active: self.command_flags.side_conversation_active,
|
||||
},
|
||||
|
||||
@@ -47,8 +47,6 @@ pub(crate) struct CommandPopupFlags {
|
||||
pub(crate) service_tier_commands_enabled: bool,
|
||||
pub(crate) goal_command_enabled: bool,
|
||||
pub(crate) personality_command_enabled: bool,
|
||||
pub(crate) realtime_conversation_enabled: bool,
|
||||
pub(crate) audio_device_selection_enabled: bool,
|
||||
pub(crate) windows_degraded_sandbox_active: bool,
|
||||
pub(crate) side_conversation_active: bool,
|
||||
}
|
||||
@@ -62,8 +60,6 @@ impl From<CommandPopupFlags> for BuiltinCommandFlags {
|
||||
service_tier_commands_enabled: value.service_tier_commands_enabled,
|
||||
goal_command_enabled: value.goal_command_enabled,
|
||||
personality_command_enabled: value.personality_command_enabled,
|
||||
realtime_conversation_enabled: value.realtime_conversation_enabled,
|
||||
audio_device_selection_enabled: value.audio_device_selection_enabled,
|
||||
allow_elevate_sandbox: value.windows_degraded_sandbox_active,
|
||||
side_conversation_active: value.side_conversation_active,
|
||||
}
|
||||
@@ -412,6 +408,26 @@ mod tests {
|
||||
insta::assert_snapshot!("command_popup_app", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn default_command_popup_items_snapshot() {
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
|
||||
let commands = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let command = item.command();
|
||||
let description = item.description();
|
||||
format!("/{command} - {description}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
insta::assert_snapshot!("command_popup_default_items", commands);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_filter_limits_matches_for_ac() {
|
||||
let mut popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
|
||||
@@ -516,8 +532,6 @@ mod tests {
|
||||
service_tier_commands_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
side_conversation_active: false,
|
||||
},
|
||||
@@ -544,8 +558,6 @@ mod tests {
|
||||
service_tier_commands_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: false,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
side_conversation_active: false,
|
||||
},
|
||||
@@ -577,8 +589,6 @@ mod tests {
|
||||
service_tier_commands_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: false,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
side_conversation_active: false,
|
||||
},
|
||||
@@ -595,40 +605,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
|
||||
let mut popup = CommandPopup::new(
|
||||
CommandPopupFlags {
|
||||
collaboration_modes_enabled: false,
|
||||
connectors_enabled: false,
|
||||
plugins_command_enabled: false,
|
||||
service_tier_commands_enabled: false,
|
||||
goal_command_enabled: false,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: false,
|
||||
windows_degraded_sandbox_active: false,
|
||||
side_conversation_active: false,
|
||||
},
|
||||
Vec::new(),
|
||||
);
|
||||
popup.on_composer_text_change("/aud".to_string());
|
||||
|
||||
let cmds: Vec<String> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => cmd.command().to_string(),
|
||||
CommandItem::ServiceTier(command) => command.name,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!cmds.iter().any(|cmd| cmd == "settings"),
|
||||
"expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_commands_are_hidden_from_popup() {
|
||||
let popup = CommandPopup::new(CommandPopupFlags::default(), Vec::new());
|
||||
|
||||
@@ -427,16 +427,6 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_realtime_conversation_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_audio_device_selection_enabled(enabled);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_side_conversation_active(&mut self, active: bool) {
|
||||
self.composer.set_side_conversation_active(active);
|
||||
self.request_redraw();
|
||||
@@ -1745,31 +1735,6 @@ impl Renderable for ChatComposerRightReserveRenderable<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl BottomPane {
|
||||
pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String {
|
||||
let id = self.composer.insert_recording_meter_placeholder(text);
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
let updated = self.composer.update_recording_meter_in_place(id, text);
|
||||
if updated {
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
|
||||
self.composer.remove_recording_meter_placeholder(id);
|
||||
self.composer.sync_popups();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for BottomPane {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_renderable().render(area, buf);
|
||||
|
||||
@@ -61,8 +61,6 @@ pub(crate) struct BuiltinCommandFlags {
|
||||
pub(crate) service_tier_commands_enabled: bool,
|
||||
pub(crate) goal_command_enabled: bool,
|
||||
pub(crate) personality_command_enabled: bool,
|
||||
pub(crate) realtime_conversation_enabled: bool,
|
||||
pub(crate) audio_device_selection_enabled: bool,
|
||||
pub(crate) allow_elevate_sandbox: bool,
|
||||
pub(crate) side_conversation_active: bool,
|
||||
}
|
||||
@@ -77,8 +75,6 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st
|
||||
.filter(|(_, cmd)| flags.plugins_command_enabled || *cmd != SlashCommand::Plugins)
|
||||
.filter(|(_, cmd)| flags.goal_command_enabled || *cmd != SlashCommand::Goal)
|
||||
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
|
||||
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
|
||||
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
|
||||
.filter(|(_, cmd)| !flags.side_conversation_active || cmd.available_in_side_conversation())
|
||||
.collect()
|
||||
}
|
||||
@@ -170,8 +166,6 @@ mod tests {
|
||||
service_tier_commands_enabled: true,
|
||||
goal_command_enabled: true,
|
||||
personality_command_enabled: true,
|
||||
realtime_conversation_enabled: true,
|
||||
audio_device_selection_enabled: true,
|
||||
allow_elevate_sandbox: true,
|
||||
side_conversation_active: false,
|
||||
}
|
||||
@@ -268,28 +262,6 @@ mod tests {
|
||||
assert_eq!(find_builtin_command("goal", flags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_command_is_hidden_when_realtime_is_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
flags.realtime_conversation_enabled = false;
|
||||
assert_eq!(find_builtin_command("realtime", flags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_command_is_hidden_when_realtime_is_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
flags.realtime_conversation_enabled = false;
|
||||
flags.audio_device_selection_enabled = false;
|
||||
assert_eq!(find_builtin_command("settings", flags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
flags.audio_device_selection_enabled = false;
|
||||
assert_eq!(find_builtin_command("settings", flags), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_conversation_hides_commands_without_side_flag() {
|
||||
let commands = builtins_for_input(BuiltinCommandFlags {
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/command_popup.rs
|
||||
expression: commands
|
||||
---
|
||||
/model - choose what model and reasoning effort to use
|
||||
/ide - include current selection, open files, and other context from your IDE
|
||||
/permissions - choose what Codex is allowed to do
|
||||
/keymap - remap TUI shortcuts
|
||||
/vim - toggle Vim mode for the composer
|
||||
/experimental - toggle experimental features
|
||||
/approve - approve one retry of a recent auto-review denial
|
||||
/memories - configure memory use and generation
|
||||
/skills - use skills to improve how Codex performs specific tasks
|
||||
/import - import setup, this project, and recent chats from Claude Code
|
||||
/hooks - view and manage lifecycle hooks
|
||||
/review - review my current changes and find issues
|
||||
/rename - rename the current thread
|
||||
/new - start a new chat during a conversation
|
||||
/archive - archive this session and exit
|
||||
/delete - permanently delete this session and exit
|
||||
/resume - resume a saved chat
|
||||
/fork - fork the current chat
|
||||
/app - continue this session in Codex Desktop
|
||||
/init - create an AGENTS.md file with instructions for Codex
|
||||
/compact - summarize conversation to prevent hitting the context limit
|
||||
/agent - switch the active agent thread
|
||||
/side - start a side conversation in an ephemeral fork
|
||||
/copy - copy last response as markdown
|
||||
/raw - toggle raw scrollback mode for copy-friendly terminal selection
|
||||
/diff - show git diff (including untracked files)
|
||||
/mention - mention a file
|
||||
/status - show current session configuration and token usage
|
||||
/title - configure which items appear in the terminal title
|
||||
/statusline - configure which items appear in the status line
|
||||
/theme - choose a syntax highlighting theme
|
||||
/pets - choose or hide the terminal pet
|
||||
/mcp - list configured MCP tools; use /mcp verbose for details
|
||||
/logout - log out of Codex
|
||||
/exit - exit Codex
|
||||
/feedback - send logs to maintainers
|
||||
/rollout - print the rollout file path
|
||||
/ps - list background terminals
|
||||
/stop - stop all background terminals
|
||||
/clear - clear the terminal and start a new chat
|
||||
/test-approval - test approval request
|
||||
/subagents - switch the active agent thread
|
||||
@@ -79,7 +79,6 @@ fn split_word_pieces(run: &str) -> Vec<(usize, &str)> {
|
||||
struct TextElement {
|
||||
id: u64,
|
||||
range: Range<usize>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -209,7 +208,6 @@ impl TextArea {
|
||||
self.elements.push(TextElement {
|
||||
id,
|
||||
range: start..end,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
self.elements.sort_by_key(|e| e.range.start);
|
||||
@@ -1470,74 +1468,13 @@ 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);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
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 {
|
||||
fn add_element(&mut self, range: Range<usize>) -> u64 {
|
||||
let id = self.next_element_id();
|
||||
let elem = TextElement { id, range, name };
|
||||
self.elements.push(elem);
|
||||
self.elements.push(TextElement { id, range });
|
||||
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, /*name*/ 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
|
||||
|
||||
@@ -44,12 +44,9 @@ use std::time::Instant;
|
||||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::HistoryLookupResponse;
|
||||
use crate::app_event::RealtimeAudioDeviceKind;
|
||||
use crate::app_server_approval_conversions::file_update_changes_to_display;
|
||||
use crate::approval_events::ApplyPatchApprovalRequestEvent;
|
||||
use crate::approval_events::ExecApprovalRequestEvent;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use crate::audio_device::list_realtime_audio_device_names;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::StatusLineSetupView;
|
||||
use crate::bottom_pane::StatusSurfacePreviewData;
|
||||
@@ -389,11 +386,9 @@ use self::rate_limits::app_server_rate_limit_error_kind;
|
||||
pub(crate) use self::rate_limits::fallback_limit_label;
|
||||
use self::rate_limits::is_app_server_cyber_policy_error;
|
||||
pub(crate) use self::rate_limits::limit_label_for_window;
|
||||
mod realtime;
|
||||
mod reasoning_shortcuts;
|
||||
mod rendering;
|
||||
mod replay;
|
||||
use self::realtime::RealtimeConversationUiState;
|
||||
mod reasoning_shortcuts;
|
||||
mod review;
|
||||
mod review_popups;
|
||||
use self::review::ReviewState;
|
||||
@@ -717,7 +712,6 @@ pub(crate) struct ChatWidget {
|
||||
current_goal_status_indicator: Option<GoalStatusIndicator>,
|
||||
current_goal_status: Option<GoalStatusState>,
|
||||
external_editor_state: ExternalEditorState,
|
||||
realtime_conversation: RealtimeConversationUiState,
|
||||
last_rendered_user_message_display: Option<UserMessageDisplay>,
|
||||
last_non_retry_error: Option<(String, String)>,
|
||||
}
|
||||
@@ -923,15 +917,6 @@ impl ChatWidget {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn realtime_conversation_enabled(&self) -> bool {
|
||||
self.config.features.enabled(Feature::RealtimeConversation)
|
||||
&& cfg!(not(target_os = "linux"))
|
||||
}
|
||||
|
||||
fn realtime_audio_device_selection_enabled(&self) -> bool {
|
||||
self.realtime_conversation_enabled()
|
||||
}
|
||||
|
||||
fn restore_retry_status_header_if_present(&mut self) {
|
||||
if let Some(header) = self.status_state.take_retry_status_header() {
|
||||
self.set_status_header(header);
|
||||
@@ -1856,7 +1841,6 @@ impl ChatWidget {
|
||||
pub(crate) fn sync_plugin_mentions_config(&mut self, config: &Config) {
|
||||
self.config.features = config.features.clone();
|
||||
self.config.config_layer_stack = config.config_layer_stack.clone();
|
||||
self.config.realtime = config.realtime.clone();
|
||||
self.config.memories = config.memories.clone();
|
||||
self.config.terminal_resize_reflow = config.terminal_resize_reflow;
|
||||
self.sync_mentions_v2_enabled();
|
||||
@@ -1962,23 +1946,6 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
impl ChatWidget {
|
||||
pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool {
|
||||
let updated = self.bottom_pane.update_recording_meter_in_place(id, text);
|
||||
if updated {
|
||||
self.request_redraw();
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
pub(crate) fn remove_recording_meter_placeholder(&mut self, id: &str) {
|
||||
self.bottom_pane.remove_recording_meter_placeholder(id);
|
||||
// Ensure the UI redraws to reflect placeholder removal.
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
|
||||
summary.responses_api_overhead_ms > 0
|
||||
|| summary.responses_api_inference_time_ms > 0
|
||||
@@ -1990,7 +1957,6 @@ fn has_websocket_timing_metrics(summary: RuntimeMetricsSummary) -> bool {
|
||||
|
||||
impl Drop for ChatWidget {
|
||||
fn drop(&mut self) {
|
||||
self.reset_realtime_conversation_state();
|
||||
self.stop_rate_limit_poller();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,6 @@ impl ChatWidget {
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
realtime_conversation: RealtimeConversationUiState::default(),
|
||||
last_rendered_user_message_display: None,
|
||||
last_non_retry_error: None,
|
||||
};
|
||||
@@ -230,12 +229,6 @@ impl ChatWidget {
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_vim_enabled(widget.config.tui_vim_mode_default);
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
|
||||
widget
|
||||
.bottom_pane
|
||||
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
|
||||
|
||||
@@ -355,20 +355,10 @@ impl ChatWidget {
|
||||
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
|
||||
/// is armed.
|
||||
///
|
||||
/// Active realtime conversations take precedence over bottom-pane Ctrl+C handling so the
|
||||
/// first press always stops live voice, even when the composer contains the recording meter.
|
||||
///
|
||||
/// When the double-press quit shortcut is enabled, pressing the same shortcut again before
|
||||
/// expiry requests a shutdown-first quit.
|
||||
fn on_ctrl_c(&mut self) {
|
||||
let key = key_hint::ctrl(KeyCode::Char('c'));
|
||||
if self.realtime_conversation.is_live() {
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
return;
|
||||
}
|
||||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||||
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
||||
|
||||
@@ -188,36 +188,6 @@ impl ChatWidget {
|
||||
self.on_shutdown_complete();
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeStarted(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_conversation_started(notification);
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeItemAdded(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_item_added(notification);
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_output_audio_delta(notification);
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeError(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_error(notification);
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeClosed(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_conversation_closed(notification);
|
||||
}
|
||||
}
|
||||
ServerNotification::ThreadRealtimeSdp(notification) => {
|
||||
if !from_replay {
|
||||
self.on_realtime_conversation_sdp(notification.sdp);
|
||||
}
|
||||
}
|
||||
ServerNotification::ServerRequestResolved(_)
|
||||
| ServerNotification::AccountUpdated(_)
|
||||
| ServerNotification::AccountRateLimitsUpdated(_)
|
||||
@@ -240,6 +210,12 @@ impl ChatWidget {
|
||||
| ServerNotification::TurnModerationMetadata(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
||||
| ServerNotification::ThreadRealtimeStarted(_)
|
||||
| ServerNotification::ThreadRealtimeItemAdded(_)
|
||||
| ServerNotification::ThreadRealtimeOutputAudioDelta(_)
|
||||
| ServerNotification::ThreadRealtimeError(_)
|
||||
| ServerNotification::ThreadRealtimeClosed(_)
|
||||
| ServerNotification::ThreadRealtimeSdp(_)
|
||||
| ServerNotification::ThreadRealtimeTranscriptDelta(_)
|
||||
| ServerNotification::ThreadRealtimeTranscriptDone(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use codex_app_server_protocol::ThreadRealtimeClosedNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeErrorNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeItemAddedNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeOutputAudioDeltaNotification;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartTransport;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
|
||||
use codex_config::config_toml::RealtimeTransport;
|
||||
use codex_realtime_webrtc::RealtimeWebrtcEvent;
|
||||
use codex_realtime_webrtc::RealtimeWebrtcSession;
|
||||
use codex_realtime_webrtc::RealtimeWebrtcSessionHandle;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use std::sync::atomic::AtomicU16;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum RealtimeConversationPhase {
|
||||
#[default]
|
||||
Inactive,
|
||||
Starting,
|
||||
Active,
|
||||
Stopping,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RealtimeConversationUiState {
|
||||
pub(super) phase: RealtimeConversationPhase,
|
||||
requested_close: bool,
|
||||
realtime_session_id: Option<String>,
|
||||
transport: RealtimeConversationUiTransport,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) meter_placeholder_id: Option<String>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
capture_stop_flag: Option<Arc<AtomicBool>>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
capture: Option<crate::voice::VoiceCapture>,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
audio_player: Option<crate::voice::RealtimeAudioPlayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
enum RealtimeConversationUiTransport {
|
||||
#[default]
|
||||
Websocket,
|
||||
Webrtc {
|
||||
handle: Option<RealtimeWebrtcSessionHandle>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RealtimeConversationUiState {
|
||||
pub(super) fn is_live(&self) -> bool {
|
||||
matches!(
|
||||
self.phase,
|
||||
RealtimeConversationPhase::Starting
|
||||
| RealtimeConversationPhase::Active
|
||||
| RealtimeConversationPhase::Stopping
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
matches!(self.phase, RealtimeConversationPhase::Active)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
fn realtime_footer_hint_items() -> Vec<(String, String)> {
|
||||
vec![("/realtime".to_string(), "stop live voice".to_string())]
|
||||
}
|
||||
|
||||
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
|
||||
if self.realtime_conversation.is_live()
|
||||
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
|
||||
{
|
||||
self.realtime_conversation.meter_placeholder_id = None;
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn start_realtime_conversation(&mut self) {
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
|
||||
self.realtime_conversation.requested_close = false;
|
||||
self.realtime_conversation.realtime_session_id = None;
|
||||
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
|
||||
match self.config.realtime.transport {
|
||||
RealtimeTransport::Websocket => {
|
||||
self.realtime_conversation.transport = RealtimeConversationUiTransport::Websocket;
|
||||
self.submit_realtime_conversation_start(/*transport*/ None);
|
||||
}
|
||||
RealtimeTransport::WebRtc => {
|
||||
self.realtime_conversation.transport =
|
||||
RealtimeConversationUiTransport::Webrtc { handle: None };
|
||||
start_realtime_webrtc_offer_task(self.app_event_tx.clone());
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn submit_realtime_conversation_start(
|
||||
&mut self,
|
||||
transport: Option<ThreadRealtimeStartTransport>,
|
||||
) {
|
||||
self.submit_op(AppCommand::realtime_conversation_start(
|
||||
transport,
|
||||
self.config
|
||||
.realtime
|
||||
.voice
|
||||
.and_then(|voice| serde_json::to_value(voice).ok()),
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) fn request_realtime_conversation_close(&mut self, info_message: Option<String>) {
|
||||
if !self.realtime_conversation.is_live() {
|
||||
if let Some(message) = info_message {
|
||||
self.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.realtime_conversation.requested_close = true;
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
|
||||
self.submit_op(AppCommand::realtime_conversation_close());
|
||||
self.stop_realtime_local_audio();
|
||||
self.close_realtime_webrtc_transport();
|
||||
self.set_footer_hint_override(/*items*/ None);
|
||||
|
||||
if let Some(message) = info_message {
|
||||
self.add_info_message(message, /*hint*/ None);
|
||||
} else {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reset_realtime_conversation_state(&mut self) {
|
||||
self.stop_realtime_local_audio();
|
||||
self.close_realtime_webrtc_transport();
|
||||
self.set_footer_hint_override(/*items*/ None);
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Inactive;
|
||||
self.realtime_conversation.requested_close = false;
|
||||
self.realtime_conversation.realtime_session_id = None;
|
||||
self.realtime_conversation.transport = RealtimeConversationUiTransport::Websocket;
|
||||
}
|
||||
|
||||
fn fail_realtime_conversation(&mut self, message: String) {
|
||||
self.add_error_message(message);
|
||||
if self.realtime_conversation.is_live() {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
} else {
|
||||
self.reset_realtime_conversation_state();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_conversation_started(
|
||||
&mut self,
|
||||
notification: ThreadRealtimeStartedNotification,
|
||||
) {
|
||||
if !self.realtime_conversation_enabled() {
|
||||
self.request_realtime_conversation_close(/*info_message*/ None);
|
||||
return;
|
||||
}
|
||||
self.realtime_conversation.realtime_session_id = notification.realtime_session_id;
|
||||
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
|
||||
if self.realtime_conversation_uses_webrtc() {
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
|
||||
} else {
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
self.start_realtime_local_audio();
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_output_audio_delta(
|
||||
&mut self,
|
||||
notification: ThreadRealtimeOutputAudioDeltaNotification,
|
||||
) {
|
||||
if self.realtime_conversation_uses_webrtc() {
|
||||
return;
|
||||
}
|
||||
self.enqueue_realtime_audio_out(¬ification.audio);
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_item_added(
|
||||
&mut self,
|
||||
notification: ThreadRealtimeItemAddedNotification,
|
||||
) {
|
||||
if self.realtime_conversation_uses_webrtc() {
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
notification
|
||||
.item
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("input_audio_buffer.speech_started" | "response.cancelled")
|
||||
) {
|
||||
self.interrupt_realtime_audio_playback();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_error(&mut self, notification: ThreadRealtimeErrorNotification) {
|
||||
self.fail_realtime_conversation(format!("Realtime voice error: {}", notification.message));
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_conversation_closed(
|
||||
&mut self,
|
||||
notification: ThreadRealtimeClosedNotification,
|
||||
) {
|
||||
if self.realtime_conversation_uses_webrtc()
|
||||
&& self.realtime_conversation.is_live()
|
||||
&& notification.reason.as_deref() == Some("transport_closed")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let requested = self.realtime_conversation.requested_close;
|
||||
let reason = notification.reason;
|
||||
self.reset_realtime_conversation_state();
|
||||
if !requested
|
||||
&& let Some(reason) = reason
|
||||
&& reason != "error"
|
||||
{
|
||||
self.add_info_message(
|
||||
format!("Realtime voice mode closed: {reason}"),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn on_realtime_conversation_sdp(&mut self, sdp: String) {
|
||||
let RealtimeConversationUiTransport::Webrtc {
|
||||
handle: Some(handle),
|
||||
} = &self.realtime_conversation.transport
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = handle.apply_answer_sdp(sdp) {
|
||||
self.fail_realtime_conversation(format!("Failed to connect realtime WebRTC: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_realtime_webrtc_offer_created(
|
||||
&mut self,
|
||||
result: Result<crate::app_event::RealtimeWebrtcOffer, String>,
|
||||
) {
|
||||
if self.realtime_conversation.phase != RealtimeConversationPhase::Starting
|
||||
|| !matches!(
|
||||
self.realtime_conversation.transport,
|
||||
RealtimeConversationUiTransport::Webrtc { handle: None }
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let offer = match result {
|
||||
Ok(offer) => offer,
|
||||
Err(err) => {
|
||||
self.fail_realtime_conversation(format!("Failed to start realtime WebRTC: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.realtime_conversation.transport = RealtimeConversationUiTransport::Webrtc {
|
||||
handle: Some(offer.handle),
|
||||
};
|
||||
self.submit_realtime_conversation_start(Some(ThreadRealtimeStartTransport::Webrtc {
|
||||
sdp: offer.offer_sdp,
|
||||
}));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn on_realtime_webrtc_event(&mut self, event: RealtimeWebrtcEvent) {
|
||||
if !self.realtime_conversation_uses_webrtc() {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
RealtimeWebrtcEvent::Connected => {
|
||||
if self.realtime_conversation.phase != RealtimeConversationPhase::Starting {
|
||||
return;
|
||||
}
|
||||
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
self.set_footer_hint_override(Some(Self::realtime_footer_hint_items()));
|
||||
self.request_redraw();
|
||||
}
|
||||
RealtimeWebrtcEvent::Closed => {
|
||||
self.reset_realtime_conversation_state();
|
||||
self.request_redraw();
|
||||
}
|
||||
RealtimeWebrtcEvent::Failed(message) => {
|
||||
self.fail_realtime_conversation(format!("Realtime WebRTC error: {message}"));
|
||||
}
|
||||
RealtimeWebrtcEvent::LocalAudioLevel(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_realtime_webrtc_local_audio_level(&mut self, peak: u16) {
|
||||
if !self.realtime_conversation_uses_webrtc() || peak == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = peak;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let RealtimeConversationUiTransport::Webrtc {
|
||||
handle: Some(handle),
|
||||
} = &self.realtime_conversation.transport
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let peak = handle.local_audio_peak();
|
||||
if self.realtime_conversation.meter_placeholder_id.is_none() {
|
||||
self.start_realtime_webrtc_meter(peak);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn realtime_conversation_uses_webrtc(&self) -> bool {
|
||||
matches!(
|
||||
self.realtime_conversation.transport,
|
||||
RealtimeConversationUiTransport::Webrtc { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn close_realtime_webrtc_transport(&mut self) {
|
||||
if let RealtimeConversationUiTransport::Webrtc { handle } =
|
||||
&mut self.realtime_conversation.transport
|
||||
&& let Some(handle) = handle.take()
|
||||
{
|
||||
handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_realtime_audio_out(&mut self, frame: &ThreadRealtimeAudioChunk) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
if self.realtime_conversation.audio_player.is_none() {
|
||||
self.realtime_conversation.audio_player =
|
||||
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
|
||||
}
|
||||
if let Some(player) = &self.realtime_conversation.audio_player
|
||||
&& let Err(err) = player.enqueue_frame(frame)
|
||||
{
|
||||
warn!("failed to play realtime audio: {err}");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = frame;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn interrupt_realtime_audio_playback(&mut self) {
|
||||
if let Some(player) = &self.realtime_conversation.audio_player {
|
||||
player.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn interrupt_realtime_audio_playback(&mut self) {}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn start_realtime_local_audio(&mut self) {
|
||||
if self.realtime_conversation.capture_stop_flag.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let capture = match crate::voice::VoiceCapture::start_realtime(
|
||||
&self.config,
|
||||
self.app_event_tx.clone(),
|
||||
) {
|
||||
Ok(capture) => capture,
|
||||
Err(err) => {
|
||||
self.fail_realtime_conversation(format!(
|
||||
"Failed to start microphone capture: {err}"
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stop_flag = capture.stopped_flag();
|
||||
let peak = capture.last_peak_arc();
|
||||
self.start_realtime_meter(stop_flag.clone(), peak);
|
||||
self.realtime_conversation.capture_stop_flag = Some(stop_flag);
|
||||
self.realtime_conversation.capture = Some(capture);
|
||||
if self.realtime_conversation.audio_player.is_none() {
|
||||
self.realtime_conversation.audio_player =
|
||||
crate::voice::RealtimeAudioPlayer::start(&self.config).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn start_realtime_webrtc_meter(&mut self, peak: Arc<AtomicU16>) {
|
||||
if self.realtime_conversation.capture_stop_flag.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let stop_flag = Arc::new(AtomicBool::new(false));
|
||||
self.start_realtime_meter(stop_flag.clone(), peak);
|
||||
self.realtime_conversation.capture_stop_flag = Some(stop_flag);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn start_realtime_meter(&mut self, stop_flag: Arc<AtomicBool>, peak: Arc<AtomicU16>) {
|
||||
let placeholder_id = self.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
|
||||
self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
|
||||
self.request_redraw();
|
||||
|
||||
start_realtime_meter_task(placeholder_id, self.app_event_tx.clone(), stop_flag, peak);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn start_realtime_local_audio(&mut self) {}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
if !self.realtime_conversation.is_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => {
|
||||
self.stop_realtime_microphone();
|
||||
self.start_realtime_local_audio();
|
||||
}
|
||||
RealtimeAudioDeviceKind::Speaker => {
|
||||
self.stop_realtime_speaker();
|
||||
match crate::voice::RealtimeAudioPlayer::start(&self.config) {
|
||||
Ok(player) => {
|
||||
self.realtime_conversation.audio_player = Some(player);
|
||||
}
|
||||
Err(err) => {
|
||||
self.fail_realtime_conversation(format!(
|
||||
"Failed to start speaker output: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
let _ = kind;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn stop_realtime_local_audio(&mut self) {
|
||||
self.stop_realtime_microphone();
|
||||
self.stop_realtime_speaker();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn stop_realtime_local_audio(&mut self) {}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn stop_realtime_microphone(&mut self) {
|
||||
if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if let Some(capture) = self.realtime_conversation.capture.take() {
|
||||
capture.stop();
|
||||
}
|
||||
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
|
||||
self.remove_recording_meter_placeholder(&id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn stop_realtime_speaker(&mut self) {
|
||||
if let Some(player) = self.realtime_conversation.audio_player.take() {
|
||||
player.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_realtime_webrtc_offer_task(app_event_tx: AppEventSender) {
|
||||
std::thread::spawn(move || {
|
||||
let result = match RealtimeWebrtcSession::start() {
|
||||
Ok(started) => {
|
||||
let event_tx = app_event_tx.clone();
|
||||
let local_audio_peak = started.handle.local_audio_peak();
|
||||
std::thread::spawn(move || {
|
||||
for event in started.events {
|
||||
if let RealtimeWebrtcEvent::LocalAudioLevel(peak) = event {
|
||||
local_audio_peak.store(peak, Ordering::Relaxed);
|
||||
event_tx.send(AppEvent::RealtimeWebrtcLocalAudioLevel(peak));
|
||||
} else {
|
||||
event_tx.send(AppEvent::RealtimeWebrtcEvent(event));
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(crate::app_event::RealtimeWebrtcOffer {
|
||||
offer_sdp: started.offer_sdp,
|
||||
handle: started.handle,
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
};
|
||||
app_event_tx.send(AppEvent::RealtimeWebrtcOfferCreated { result });
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn start_realtime_meter_task(
|
||||
meter_placeholder_id: String,
|
||||
app_event_tx: AppEventSender,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
peak: Arc<AtomicU16>,
|
||||
) {
|
||||
std::thread::spawn(move || {
|
||||
let mut meter = crate::voice::RecordingMeterState::new();
|
||||
|
||||
loop {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let meter_text = meter.next_text(peak.load(Ordering::Relaxed));
|
||||
app_event_tx.send(AppEvent::UpdateRecordingMeter {
|
||||
id: meter_placeholder_id.clone(),
|
||||
text: meter_text,
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -75,18 +75,6 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
let enabled = self.config.features.enabled(feature);
|
||||
if feature == Feature::RealtimeConversation {
|
||||
let realtime_conversation_enabled = self.realtime_conversation_enabled();
|
||||
self.bottom_pane
|
||||
.set_realtime_conversation_enabled(realtime_conversation_enabled);
|
||||
self.bottom_pane
|
||||
.set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled());
|
||||
if !realtime_conversation_enabled && self.realtime_conversation.is_live() {
|
||||
self.request_realtime_conversation_close(Some(
|
||||
"Realtime voice mode was closed because the feature was disabled.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if feature == Feature::FastMode {
|
||||
self.refresh_effective_service_tier();
|
||||
self.sync_service_tier_commands();
|
||||
@@ -231,17 +219,6 @@ impl ChatWidget {
|
||||
.set_connectors_enabled(self.connectors_enabled());
|
||||
}
|
||||
|
||||
pub(crate) fn set_realtime_audio_device(
|
||||
&mut self,
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
name: Option<String>,
|
||||
) {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name,
|
||||
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the syntax theme override in the widget's config copy.
|
||||
pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) {
|
||||
self.config.tui_theme = theme;
|
||||
@@ -273,28 +250,6 @@ impl ChatWidget {
|
||||
.unwrap_or_else(|| self.current_collaboration_mode.model())
|
||||
}
|
||||
|
||||
pub(crate) fn realtime_conversation_is_live(&self) -> bool {
|
||||
self.realtime_conversation.is_live()
|
||||
}
|
||||
|
||||
pub(super) fn current_realtime_audio_device_name(
|
||||
&self,
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
) -> Option<String> {
|
||||
match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(),
|
||||
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn current_realtime_audio_selection_label(
|
||||
&self,
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
) -> String {
|
||||
self.current_realtime_audio_device_name(kind)
|
||||
.unwrap_or_else(|| "System default".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn sync_personality_command_enabled(&mut self) {
|
||||
self.bottom_pane
|
||||
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Settings-adjacent popup surfaces for `ChatWidget`.
|
||||
//!
|
||||
//! This keeps theme, personality, audio-device, and experimental-feature UI
|
||||
//! out of the main orchestration module without changing their event wiring.
|
||||
//! This keeps theme, personality, and experimental-feature UI out of the main
|
||||
//! orchestration module without changing their event wiring.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -90,161 +90,6 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_realtime_audio_popup(&mut self) {
|
||||
let items = [
|
||||
RealtimeAudioDeviceKind::Microphone,
|
||||
RealtimeAudioDeviceKind::Speaker,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|kind| {
|
||||
let description = Some(format!(
|
||||
"Current: {}",
|
||||
self.current_realtime_audio_selection_label(kind)
|
||||
));
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind });
|
||||
})];
|
||||
SelectionItem {
|
||||
name: kind.title().to_string(),
|
||||
description,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Settings".to_string()),
|
||||
subtitle: Some("Configure settings for Codex.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
match list_realtime_audio_device_names(kind) {
|
||||
Ok(device_names) => {
|
||||
self.open_realtime_audio_device_selection_with_names(kind, device_names);
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_error_message(format!(
|
||||
"Failed to load realtime {} devices: {err}",
|
||||
kind.noun()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
let _ = kind;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn open_realtime_audio_device_selection_with_names(
|
||||
&mut self,
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
device_names: Vec<String>,
|
||||
) {
|
||||
let current_selection = self.current_realtime_audio_device_name(kind);
|
||||
let current_available = current_selection
|
||||
.as_deref()
|
||||
.is_some_and(|name| device_names.iter().any(|device_name| device_name == name));
|
||||
let mut items = vec![SelectionItem {
|
||||
name: "System default".to_string(),
|
||||
description: Some("Use your operating system default device.".to_string()),
|
||||
is_current: current_selection.is_none(),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None });
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}];
|
||||
|
||||
if let Some(selection) = current_selection.as_deref()
|
||||
&& !current_available
|
||||
{
|
||||
items.push(SelectionItem {
|
||||
name: format!("Unavailable: {selection}"),
|
||||
description: Some("Configured device is not currently available.".to_string()),
|
||||
is_current: true,
|
||||
is_disabled: true,
|
||||
disabled_reason: Some("Reconnect the device or choose another one.".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
items.extend(device_names.into_iter().map(|device_name| {
|
||||
let persisted_name = device_name.clone();
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection {
|
||||
kind,
|
||||
name: Some(persisted_name.clone()),
|
||||
});
|
||||
})];
|
||||
SelectionItem {
|
||||
is_current: current_selection.as_deref() == Some(device_name.as_str()),
|
||||
name: device_name,
|
||||
actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}
|
||||
}));
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from(format!("Select {}", kind.title()).bold()));
|
||||
header.push(Line::from(
|
||||
"Saved devices apply to realtime voice only.".dim(),
|
||||
));
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) {
|
||||
let restart_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::RestartRealtimeAudioDevice { kind });
|
||||
})];
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Restart now".to_string(),
|
||||
description: Some(format!("Restart local {} audio now.", kind.noun())),
|
||||
actions: restart_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Apply later".to_string(),
|
||||
description: Some(format!(
|
||||
"Keep the current {} until local audio starts again.",
|
||||
kind.noun()
|
||||
)),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
let mut header = ColumnRenderable::new();
|
||||
header.push(Line::from(format!("Restart {} now?", kind.title()).bold()));
|
||||
header.push(Line::from(
|
||||
"Configuration is saved. Restart local audio to use it immediately.".dim(),
|
||||
));
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
header: Box::new(header),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_experimental_popup(&mut self) {
|
||||
let features: Vec<ExperimentalFeatureItem> = FEATURES
|
||||
.iter()
|
||||
|
||||
@@ -261,22 +261,6 @@ impl ChatWidget {
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
}
|
||||
SlashCommand::Realtime => {
|
||||
if !self.realtime_conversation_enabled() {
|
||||
return;
|
||||
}
|
||||
if self.realtime_conversation.is_live() {
|
||||
self.stop_realtime_conversation_from_ui();
|
||||
} else {
|
||||
self.start_realtime_conversation();
|
||||
}
|
||||
}
|
||||
SlashCommand::Settings => {
|
||||
if !self.realtime_audio_device_selection_enabled() {
|
||||
return;
|
||||
}
|
||||
self.open_realtime_audio_popup();
|
||||
}
|
||||
SlashCommand::Personality => {
|
||||
self.open_personality_popup();
|
||||
}
|
||||
@@ -962,8 +946,6 @@ impl ChatWidget {
|
||||
goal_command_enabled: self.config.features.enabled(Feature::Goals),
|
||||
service_tier_commands_enabled: self.fast_mode_enabled(),
|
||||
personality_command_enabled: self.config.features.enabled(Feature::Personality),
|
||||
realtime_conversation_enabled: self.realtime_conversation_enabled(),
|
||||
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
|
||||
allow_elevate_sandbox,
|
||||
side_conversation_active: self.active_side_conversation,
|
||||
}
|
||||
@@ -1003,8 +985,6 @@ impl ChatWidget {
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Realtime
|
||||
| SlashCommand::Settings
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Goal
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
Configure settings for Codex.
|
||||
|
||||
› 1. Microphone Current: System default
|
||||
2. Speaker Current: System default
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Settings
|
||||
Configure settings for Codex.
|
||||
|
||||
› 1. Microphone Current: System default
|
||||
2. Speaker Current: System default
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/popups_and_settings.rs
|
||||
expression: popup
|
||||
---
|
||||
Select Microphone
|
||||
Saved devices apply to realtime voice only.
|
||||
|
||||
› 1. System default Use your operating system
|
||||
default device.
|
||||
Unavailable: Studio Mic (current) (disabled) Configured device is not
|
||||
currently available.
|
||||
(disabled: Reconnect the
|
||||
device or choose another
|
||||
one.)
|
||||
2. Built-in Mic
|
||||
3. USB Mic
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -8,15 +8,12 @@ pub(super) use super::*;
|
||||
pub(super) use crate::app_command::AppCommand as Op;
|
||||
pub(super) use crate::app_event::AppEvent;
|
||||
pub(super) use crate::app_event::ExitMode;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) use crate::app_event::RealtimeAudioDeviceKind;
|
||||
pub(super) use crate::app_event_sender::AppEventSender;
|
||||
pub(super) use crate::approval_events::ApplyPatchApprovalRequestEvent;
|
||||
pub(super) use crate::approval_events::ExecApprovalRequestEvent;
|
||||
pub(super) use crate::bottom_pane::LocalImageAttachment;
|
||||
pub(super) use crate::bottom_pane::MentionBinding;
|
||||
pub(super) use crate::bottom_pane::QueuedInputAction;
|
||||
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
|
||||
pub(super) use crate::diff_model::FileChange;
|
||||
pub(super) use crate::history_cell::UserHistoryCell;
|
||||
pub(super) use crate::legacy_core::config::Config;
|
||||
@@ -104,8 +101,6 @@ pub(super) use codex_app_server_protocol::ServerNotification;
|
||||
pub(super) use codex_app_server_protocol::SkillSummary;
|
||||
pub(super) use codex_app_server_protocol::ThreadClosedNotification;
|
||||
pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
|
||||
pub(super) use codex_app_server_protocol::ThreadRealtimeClosedNotification;
|
||||
pub(super) use codex_app_server_protocol::ThreadRealtimeErrorNotification;
|
||||
pub(super) use codex_app_server_protocol::ToolRequestUserInputOption;
|
||||
pub(super) use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
pub(super) use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
|
||||
@@ -217,21 +217,6 @@ pub(super) fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::RealtimeConversationClose) => return,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => {
|
||||
panic!("expected realtime close op but queue was empty")
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
panic!("expected realtime close op but channel closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
assert!(
|
||||
|
||||
@@ -10,47 +10,6 @@ use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_features::Stage;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn realtime_error_closes_without_followup_closed_info() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
|
||||
chat.on_realtime_error(ThreadRealtimeErrorNotification {
|
||||
thread_id: ThreadId::new().to_string(),
|
||||
message: "boom".to_string(),
|
||||
});
|
||||
next_realtime_close_op(&mut op_rx);
|
||||
|
||||
chat.on_realtime_conversation_closed(ThreadRealtimeClosedNotification {
|
||||
thread_id: ThreadId::new().to_string(),
|
||||
reason: Some("error".to_string()),
|
||||
});
|
||||
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<Vec<_>>();
|
||||
insta::assert_snapshot!(rendered.join("\n\n"), @"■ Realtime voice error: boom");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn deleted_realtime_meter_uses_shared_stop_path() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
let placeholder_id = chat.bottom_pane.insert_recording_meter_placeholder("⠤⠤⠤⠤");
|
||||
chat.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
|
||||
|
||||
assert!(chat.stop_realtime_conversation_for_deleted_meter(&placeholder_id));
|
||||
|
||||
next_realtime_close_op(&mut op_rx);
|
||||
assert_eq!(chat.realtime_conversation.meter_placeholder_id, None);
|
||||
assert_eq!(
|
||||
chat.realtime_conversation.phase,
|
||||
RealtimeConversationPhase::Stopping
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn experimental_mode_plan_is_ignored_on_startup() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -2616,62 +2575,6 @@ async fn personality_selection_popup_snapshot() {
|
||||
assert_chatwidget_snapshot!("personality_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn realtime_audio_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||||
chat.open_realtime_audio_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("realtime_audio_selection_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn realtime_audio_selection_popup_narrow_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||||
chat.open_realtime_audio_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 56);
|
||||
assert_chatwidget_snapshot!("realtime_audio_selection_popup_narrow", popup);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn realtime_microphone_picker_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||||
chat.config.realtime_audio.microphone = Some("Studio Mic".to_string());
|
||||
chat.open_realtime_audio_device_selection_with_names(
|
||||
RealtimeAudioDeviceKind::Microphone,
|
||||
vec!["Built-in Mic".to_string(), "USB Mic".to_string()],
|
||||
);
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert_chatwidget_snapshot!("realtime_microphone_picker_popup", popup);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[tokio::test]
|
||||
async fn realtime_audio_picker_emits_persist_event() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
|
||||
chat.open_realtime_audio_device_selection_with_names(
|
||||
RealtimeAudioDeviceKind::Speaker,
|
||||
vec!["Desk Speakers".to_string(), "Headphones".to_string()],
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::PersistRealtimeAudioDeviceSelection {
|
||||
kind: RealtimeAudioDeviceKind::Speaker,
|
||||
name: Some(name),
|
||||
}) if name == "Headphones"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;
|
||||
|
||||
@@ -1004,25 +1004,6 @@ async fn ctrl_c_interrupts_without_arming_quit_when_double_press_disabled() {
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_closes_realtime_conversation_before_interrupt_or_quit() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
|
||||
chat.bottom_pane
|
||||
.set_composer_text("recording meter".to_string(), Vec::new(), Vec::new());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
next_realtime_close_op(&mut op_rx);
|
||||
assert_eq!(
|
||||
chat.realtime_conversation.phase,
|
||||
RealtimeConversationPhase::Stopping
|
||||
);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "recording meter");
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -94,22 +94,6 @@ mod app_server_approval_conversions;
|
||||
mod app_server_session;
|
||||
mod approval_events;
|
||||
mod ascii_animation;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod audio_device;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(dead_code)]
|
||||
mod audio_device {
|
||||
use crate::app_event::RealtimeAudioDeviceKind;
|
||||
|
||||
pub(crate) fn list_realtime_audio_device_names(
|
||||
kind: RealtimeAudioDeviceKind,
|
||||
) -> Result<Vec<String>, String> {
|
||||
Err(format!(
|
||||
"Failed to load realtime {} devices: voice input is unavailable in this build",
|
||||
kind.noun()
|
||||
))
|
||||
}
|
||||
}
|
||||
mod bottom_pane;
|
||||
mod branch_summary;
|
||||
mod chatwidget;
|
||||
@@ -210,69 +194,10 @@ mod updates;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
mod updates_cache;
|
||||
mod version;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod voice;
|
||||
mod width;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod windows_sandbox;
|
||||
mod workspace_command;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(dead_code)]
|
||||
mod voice {
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::legacy_core::config::Config;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU16;
|
||||
|
||||
pub struct VoiceCapture;
|
||||
|
||||
pub(crate) struct RecordingMeterState;
|
||||
|
||||
pub(crate) struct RealtimeAudioPlayer;
|
||||
|
||||
impl VoiceCapture {
|
||||
pub fn start_realtime(_config: &Config, _tx: AppEventSender) -> Result<Self, String> {
|
||||
Err("voice input is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub fn stop(self) {}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::new(AtomicBool::new(true))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
impl RealtimeAudioPlayer {
|
||||
pub(crate) fn start(_config: &Config) -> Result<Self, String> {
|
||||
Err("voice output is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_frame(
|
||||
&self,
|
||||
_frame: &ThreadRealtimeAudioChunk,
|
||||
) -> Result<(), String> {
|
||||
Err("voice output is unavailable in this build".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {}
|
||||
}
|
||||
}
|
||||
|
||||
mod wrapping;
|
||||
|
||||
|
||||
@@ -67,8 +67,6 @@ pub enum SlashCommand {
|
||||
Stop,
|
||||
Clear,
|
||||
Personality,
|
||||
Realtime,
|
||||
Settings,
|
||||
TestApproval,
|
||||
#[strum(serialize = "subagents")]
|
||||
MultiAgents,
|
||||
@@ -118,8 +116,6 @@ impl SlashCommand {
|
||||
"include current selection, open files, and other context from your IDE"
|
||||
}
|
||||
SlashCommand::Personality => "choose a communication style for Codex",
|
||||
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
|
||||
SlashCommand::Settings => "configure realtime microphone/speaker",
|
||||
SlashCommand::Plan => "switch to Plan mode",
|
||||
SlashCommand::Goal => "set or view the goal for a long-running task",
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
|
||||
@@ -237,8 +233,6 @@ impl SlashCommand {
|
||||
| SlashCommand::Btw => true,
|
||||
SlashCommand::Rollout => true,
|
||||
SlashCommand::TestApproval => true,
|
||||
SlashCommand::Realtime => true,
|
||||
SlashCommand::Settings => true,
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => true,
|
||||
SlashCommand::Theme | SlashCommand::Pets => false,
|
||||
}
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::legacy_core::config::Config;
|
||||
use base64::Engine;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
use cpal::traits::DeviceTrait;
|
||||
use cpal::traits::StreamTrait;
|
||||
use std::collections::VecDeque;
|
||||
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;
|
||||
|
||||
const MODEL_AUDIO_SAMPLE_RATE: u32 = 24_000;
|
||||
const MODEL_AUDIO_CHANNELS: u16 = 1;
|
||||
|
||||
pub struct VoiceCapture {
|
||||
stream: Option<cpal::Stream>,
|
||||
stopped: Arc<AtomicBool>,
|
||||
last_peak: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl VoiceCapture {
|
||||
pub fn start_realtime(config: &Config, tx: AppEventSender) -> Result<Self, String> {
|
||||
let (device, config) = select_realtime_input_device_and_config(config)?;
|
||||
|
||||
let sample_rate = config.sample_rate().0;
|
||||
let channels = config.channels();
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let last_peak = Arc::new(AtomicU16::new(0));
|
||||
|
||||
let stream = build_realtime_input_stream(
|
||||
&device,
|
||||
&config,
|
||||
sample_rate,
|
||||
channels,
|
||||
tx,
|
||||
last_peak.clone(),
|
||||
)?;
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| format!("failed to start input stream: {e}"))?;
|
||||
|
||||
Ok(Self {
|
||||
stream: Some(stream),
|
||||
stopped,
|
||||
last_peak,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stop(mut self) {
|
||||
// Mark stopped so any metering task can exit cleanly.
|
||||
self.stopped.store(true, Ordering::SeqCst);
|
||||
// Dropping the stream stops capture.
|
||||
self.stream.take();
|
||||
}
|
||||
|
||||
pub fn stopped_flag(&self) -> Arc<AtomicBool> {
|
||||
self.stopped.clone()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Voice input helpers
|
||||
// -------------------------
|
||||
|
||||
fn select_realtime_input_device_and_config(
|
||||
config: &Config,
|
||||
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
|
||||
crate::audio_device::select_configured_input_device_and_config(config)
|
||||
}
|
||||
|
||||
fn build_realtime_input_stream(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
tx: AppEventSender,
|
||||
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);
|
||||
let samples = input.iter().copied().map(f32_to_i16).collect::<Vec<_>>();
|
||||
send_realtime_audio_chunk(&tx, samples, sample_rate, channels);
|
||||
},
|
||||
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);
|
||||
send_realtime_audio_chunk(&tx, input.to_vec(), sample_rate, channels);
|
||||
},
|
||||
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], _| {
|
||||
let mut samples = Vec::with_capacity(input.len());
|
||||
let peak = convert_u16_to_i16_and_peak(input, &mut samples);
|
||||
last_peak.store(peak, Ordering::Relaxed);
|
||||
send_realtime_audio_chunk(&tx, samples, sample_rate, channels);
|
||||
},
|
||||
move |err| error!("audio input error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build input stream: {e}")),
|
||||
_ => Err("unsupported input sample format".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_realtime_audio_chunk(
|
||||
tx: &AppEventSender,
|
||||
samples: Vec<i16>,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
) {
|
||||
if samples.is_empty() || sample_rate == 0 || channels == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let samples = if sample_rate == MODEL_AUDIO_SAMPLE_RATE && channels == MODEL_AUDIO_CHANNELS {
|
||||
samples
|
||||
} else {
|
||||
convert_pcm16(
|
||||
&samples,
|
||||
sample_rate,
|
||||
channels,
|
||||
MODEL_AUDIO_SAMPLE_RATE,
|
||||
MODEL_AUDIO_CHANNELS,
|
||||
)
|
||||
};
|
||||
if samples.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut bytes = Vec::with_capacity(samples.len() * 2);
|
||||
for sample in &samples {
|
||||
bytes.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let samples_per_channel = (samples.len() / usize::from(MODEL_AUDIO_CHANNELS)) as u32;
|
||||
|
||||
tx.realtime_conversation_audio(ThreadRealtimeAudioChunk {
|
||||
data: encoded,
|
||||
sample_rate: MODEL_AUDIO_SAMPLE_RATE,
|
||||
num_channels: MODEL_AUDIO_CHANNELS,
|
||||
samples_per_channel: Some(samples_per_channel),
|
||||
item_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Realtime audio playback helpers
|
||||
// -------------------------
|
||||
|
||||
pub(crate) struct RealtimeAudioPlayer {
|
||||
_stream: cpal::Stream,
|
||||
queue: Arc<Mutex<VecDeque<i16>>>,
|
||||
output_sample_rate: u32,
|
||||
output_channels: u16,
|
||||
}
|
||||
|
||||
impl RealtimeAudioPlayer {
|
||||
pub(crate) fn start(config: &Config) -> Result<Self, String> {
|
||||
let (device, config) =
|
||||
crate::audio_device::select_configured_output_device_and_config(config)?;
|
||||
let output_sample_rate = config.sample_rate().0;
|
||||
let output_channels = config.channels();
|
||||
let queue = Arc::new(Mutex::new(VecDeque::new()));
|
||||
let stream = build_output_stream(&device, &config, Arc::clone(&queue))?;
|
||||
stream
|
||||
.play()
|
||||
.map_err(|e| format!("failed to start output stream: {e}"))?;
|
||||
Ok(Self {
|
||||
_stream: stream,
|
||||
queue,
|
||||
output_sample_rate,
|
||||
output_channels,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_frame(&self, frame: &ThreadRealtimeAudioChunk) -> Result<(), String> {
|
||||
if frame.num_channels == 0 || frame.sample_rate == 0 {
|
||||
return Err("invalid realtime audio frame format".to_string());
|
||||
}
|
||||
let raw_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&frame.data)
|
||||
.map_err(|e| format!("failed to decode realtime audio: {e}"))?;
|
||||
if raw_bytes.len() % 2 != 0 {
|
||||
return Err("realtime audio frame had odd byte length".to_string());
|
||||
}
|
||||
let mut pcm = Vec::with_capacity(raw_bytes.len() / 2);
|
||||
for pair in raw_bytes.chunks_exact(2) {
|
||||
pcm.push(i16::from_le_bytes([pair[0], pair[1]]));
|
||||
}
|
||||
let converted = convert_pcm16(
|
||||
&pcm,
|
||||
frame.sample_rate,
|
||||
frame.num_channels,
|
||||
self.output_sample_rate,
|
||||
self.output_channels,
|
||||
);
|
||||
if converted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut guard = self
|
||||
.queue
|
||||
.lock()
|
||||
.map_err(|_| "failed to lock output audio queue".to_string())?;
|
||||
// TODO(aibrahim): Cap or trim this queue if we observe producer bursts outrunning playback.
|
||||
guard.extend(converted);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
if let Ok(mut guard) = self.queue.lock() {
|
||||
guard.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_output_stream(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
queue: Arc<Mutex<VecDeque<i16>>>,
|
||||
) -> Result<cpal::Stream, String> {
|
||||
let config_any: cpal::StreamConfig = config.clone().into();
|
||||
match config.sample_format() {
|
||||
cpal::SampleFormat::F32 => device
|
||||
.build_output_stream(
|
||||
&config_any,
|
||||
move |output: &mut [f32], _| fill_output_f32(output, &queue),
|
||||
move |err| error!("audio output error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build f32 output stream: {e}")),
|
||||
cpal::SampleFormat::I16 => device
|
||||
.build_output_stream(
|
||||
&config_any,
|
||||
move |output: &mut [i16], _| fill_output_i16(output, &queue),
|
||||
move |err| error!("audio output error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build i16 output stream: {e}")),
|
||||
cpal::SampleFormat::U16 => device
|
||||
.build_output_stream(
|
||||
&config_any,
|
||||
move |output: &mut [u16], _| fill_output_u16(output, &queue),
|
||||
move |err| error!("audio output error: {err}"),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| format!("failed to build u16 output stream: {e}")),
|
||||
other => Err(format!("unsupported output sample format: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_output_i16(output: &mut [i16], queue: &Arc<Mutex<VecDeque<i16>>>) {
|
||||
if let Ok(mut guard) = queue.lock() {
|
||||
for sample in output {
|
||||
*sample = guard.pop_front().unwrap_or(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
output.fill(0);
|
||||
}
|
||||
|
||||
fn fill_output_f32(output: &mut [f32], queue: &Arc<Mutex<VecDeque<i16>>>) {
|
||||
if let Ok(mut guard) = queue.lock() {
|
||||
for sample in output {
|
||||
let v = guard.pop_front().unwrap_or(0);
|
||||
*sample = (v as f32) / (i16::MAX as f32);
|
||||
}
|
||||
return;
|
||||
}
|
||||
output.fill(0.0);
|
||||
}
|
||||
|
||||
fn fill_output_u16(output: &mut [u16], queue: &Arc<Mutex<VecDeque<i16>>>) {
|
||||
if let Ok(mut guard) = queue.lock() {
|
||||
for sample in output {
|
||||
let v = guard.pop_front().unwrap_or(0);
|
||||
*sample = (v as i32 + 32768).clamp(0, u16::MAX as i32) as u16;
|
||||
}
|
||||
return;
|
||||
}
|
||||
output.fill(32768);
|
||||
}
|
||||
|
||||
fn convert_pcm16(
|
||||
input: &[i16],
|
||||
input_sample_rate: u32,
|
||||
input_channels: u16,
|
||||
output_sample_rate: u32,
|
||||
output_channels: u16,
|
||||
) -> Vec<i16> {
|
||||
if input.is_empty() || input_channels == 0 || output_channels == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let in_channels = input_channels as usize;
|
||||
let out_channels = output_channels as usize;
|
||||
let in_frames = input.len() / in_channels;
|
||||
if in_frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let out_frames = if input_sample_rate == output_sample_rate {
|
||||
in_frames
|
||||
} else {
|
||||
(((in_frames as u64) * (output_sample_rate as u64)) / (input_sample_rate as u64)).max(1)
|
||||
as usize
|
||||
};
|
||||
|
||||
let mut out = Vec::with_capacity(out_frames.saturating_mul(out_channels));
|
||||
for out_frame_idx in 0..out_frames {
|
||||
let src_frame_idx = if out_frames <= 1 || in_frames <= 1 {
|
||||
0
|
||||
} else {
|
||||
((out_frame_idx as u64) * ((in_frames - 1) as u64) / ((out_frames - 1) as u64)) as usize
|
||||
};
|
||||
let src_start = src_frame_idx.saturating_mul(in_channels);
|
||||
let src = &input[src_start..src_start + in_channels];
|
||||
match (in_channels, out_channels) {
|
||||
(1, 1) => out.push(src[0]),
|
||||
(1, n) => {
|
||||
for _ in 0..n {
|
||||
out.push(src[0]);
|
||||
}
|
||||
}
|
||||
(n, 1) if n >= 2 => {
|
||||
let sum: i32 = src.iter().map(|s| *s as i32).sum();
|
||||
out.push((sum / (n as i32)) as i16);
|
||||
}
|
||||
(n, m) if n == m => out.extend_from_slice(src),
|
||||
(n, m) if n > m => out.extend_from_slice(&src[..m]),
|
||||
(n, m) => {
|
||||
out.extend_from_slice(src);
|
||||
let last = *src.last().unwrap_or(&0);
|
||||
for _ in n..m {
|
||||
out.push(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::convert_pcm16;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn convert_pcm16_downmixes_and_resamples_for_model_input() {
|
||||
let input = vec![100, 300, 200, 400, 500, 700, 600, 800];
|
||||
let converted = convert_pcm16(
|
||||
&input, /*input_sample_rate*/ 48_000, /*input_channels*/ 2,
|
||||
/*output_sample_rate*/ 24_000, /*output_channels*/ 1,
|
||||
);
|
||||
assert_eq!(converted, vec![200, 700]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user