voice transcription (#3381)

Adds voice transcription on press-and-hold of spacebar.


https://github.com/user-attachments/assets/85039314-26f3-46d1-a83b-8c4a4a1ecc21

---------

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
Co-authored-by: David Zbarsky <zbarsky@openai.com>
This commit is contained in:
Jeremy Rose
2026-02-23 14:15:18 -08:00
committed by GitHub
parent 50953ea39a
commit 855e275591
17 changed files with 2538 additions and 446 deletions

View File

@@ -33,6 +33,7 @@ use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
@@ -204,8 +205,8 @@ impl BottomPane {
placeholder_text,
disable_paste_burst,
);
composer.set_frame_requester(frame_requester.clone());
composer.set_skill_mentions(skills);
Self {
composer,
view_stack: Vec::new(),
@@ -291,6 +292,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.composer.set_voice_transcription_enabled(enabled);
self.request_redraw();
}
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
@@ -327,8 +333,23 @@ impl BottomPane {
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
// Do not globally intercept space; only composer handles hold-to-talk.
// While recording, route all keys to the composer so it can stop on release or next key.
#[cfg(not(target_os = "linux"))]
if self.composer.is_recording() {
let (_ir, needs_redraw) = self.composer.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
}
return InputResult::None;
}
// If a modal/view is active, handle it here; otherwise forward to composer.
if !self.view_stack.is_empty() {
if key_event.kind == KeyEventKind::Release {
return InputResult::None;
}
// We need three pieces of information after routing the key:
// whether Esc completed the view, whether the view finished for any
// reason, and whether a paste-burst timer should be scheduled.
@@ -432,6 +453,7 @@ impl BottomPane {
}
} else {
let needs_redraw = self.composer.handle_paste(pasted);
self.composer.sync_popups();
if needs_redraw {
self.request_redraw();
}
@@ -440,9 +462,18 @@ impl BottomPane {
pub(crate) fn insert_str(&mut self, text: &str) {
self.composer.insert_str(text);
self.composer.sync_popups();
self.request_redraw();
}
// Space hold timeout is handled inside ChatComposer via an internal timer.
pub(crate) fn pre_draw_tick(&mut self) {
// Allow composer to process any time-based transitions before drawing
#[cfg(not(target_os = "linux"))]
self.composer.process_space_hold_trigger();
self.composer.sync_popups();
}
/// Replace the composer text with `text`.
///
/// This is intended for fresh input where mention linkage does not need to
@@ -895,6 +926,7 @@ impl BottomPane {
.on_history_entry_response(log_id, offset, entry);
if updated {
self.composer.sync_popups();
self.request_redraw();
}
}
@@ -973,6 +1005,30 @@ impl BottomPane {
}
}
#[cfg(not(target_os = "linux"))]
impl BottomPane {
pub(crate) fn replace_transcription(&mut self, id: &str, text: &str) {
self.composer.replace_transcription(id, text);
self.composer.sync_popups();
self.request_redraw();
}
pub(crate) fn update_transcription_in_place(&mut self, id: &str, text: &str) -> bool {
let updated = self.composer.update_transcription_in_place(id, text);
if updated {
self.composer.sync_popups();
self.request_redraw();
}
updated
}
pub(crate) fn remove_transcription_placeholder(&mut self, id: &str) {
self.composer.remove_transcription_placeholder(id);
self.composer.sync_popups();
self.request_redraw();
}
}
impl Renderable for BottomPane {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_renderable().render(area, buf);
@@ -993,6 +1049,7 @@ mod tests {
use crate::status_indicator_widget::StatusDetailsCapitalization;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SkillScope;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use ratatui::buffer::Buffer;
@@ -1571,4 +1628,58 @@ mod tests {
assert_eq!(on_ctrl_c_calls.get(), 0);
assert_eq!(handle_calls.get(), 1);
}
#[test]
fn release_events_are_ignored_for_active_view() {
#[derive(Default)]
struct CountingView {
handle_calls: Rc<Cell<usize>>,
}
impl Renderable for CountingView {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl BottomPaneView for CountingView {
fn handle_key_event(&mut self, _key_event: KeyEvent) {
self.handle_calls
.set(self.handle_calls.get().saturating_add(1));
}
}
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
let handle_calls = Rc::new(Cell::new(0));
pane.push_view(Box::new(CountingView {
handle_calls: Rc::clone(&handle_calls),
}));
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Down,
KeyModifiers::NONE,
KeyEventKind::Press,
));
pane.handle_key_event(KeyEvent::new_with_kind(
KeyCode::Down,
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert_eq!(handle_calls.get(), 1);
}
}