mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Respect reduced motion in the voice UI (#46835)
## Why Voice meters and partial user transcripts continued updating with `tui.animations = false`. ## What changed - Hide microphone and speaker meter histories when animations are disabled, and size the composer to fit the remaining voice controls. - Keep partial user transcripts out of the conversation until finalized while preserving transcript tracking and final history entries. - Skip footer updates caused solely by audio meter changes when animations are disabled, while retaining speaker activity transitions. ## Testing Update transcript, replay, and rendering tests to cover hidden partial transcripts and finalized history. Adjust voice composer snapshots and clipped-layout coverage for the reduced height. GitOrigin-RevId: fb1939d6d8b3edc60fdd0a33ba7798d37bd9337c
This commit is contained in:
@@ -23,11 +23,11 @@ impl ChatComposer {
|
||||
|
||||
pub(super) fn render_voice_strip(&self, composer_rect: Rect, buf: &mut Buffer) {
|
||||
if let Some(strip) = &self.voice_strip
|
||||
&& composer_rect.height >= 6
|
||||
&& composer_rect.height >= strip.desired_height(composer_rect.width) + 4
|
||||
{
|
||||
let voice_rect = Rect {
|
||||
y: composer_rect.y.saturating_add(/*rhs*/ 1),
|
||||
height: 2,
|
||||
height: strip.desired_height(composer_rect.width),
|
||||
..composer_rect
|
||||
};
|
||||
strip.render(voice_rect, buf);
|
||||
@@ -69,7 +69,10 @@ impl ChatComposer {
|
||||
.active
|
||||
.required_height(area.width, footer_hint_height)
|
||||
};
|
||||
let voice_rows = if self.voice_strip.is_some() { 3 } else { 0 };
|
||||
let voice_rows = self
|
||||
.voice_strip
|
||||
.as_ref()
|
||||
.map_or(0, |strip| strip.desired_height(area.width) + 1);
|
||||
let shortcuts_above = self.shortcuts_above_composer(options);
|
||||
let (composer_rect, popup_rect, mut footer_rect) = if shortcuts_above {
|
||||
let [shortcuts, composer, footer] = Layout::vertical([
|
||||
@@ -100,7 +103,7 @@ impl ChatComposer {
|
||||
};
|
||||
footer_rect.y = footer_rect.y.saturating_add(status.height);
|
||||
// Keep the draft visible when clipped.
|
||||
let voice_rows = voice_rows * u16::from(composer_rect.height >= 6);
|
||||
let voice_rows = voice_rows * u16::from(composer_rect.height >= voice_rows + 3);
|
||||
let mut textarea_rect = composer_rect.inset(Insets::tlbr(
|
||||
/*top*/ 1 + voice_rows,
|
||||
LIVE_PREFIX_COLS,
|
||||
@@ -190,7 +193,10 @@ impl ChatComposer {
|
||||
0
|
||||
}
|
||||
+ 2
|
||||
+ if self.voice_strip.is_some() { 3 } else { 0 }
|
||||
+ self
|
||||
.voice_strip
|
||||
.as_ref()
|
||||
.map_or(0, |strip| strip.desired_height(width) + 1)
|
||||
+ if self.popups.active.is_above_composer()
|
||||
&& options.command_popup_placement != CommandPopupPlacement::AboveComposer
|
||||
{
|
||||
|
||||
-1
@@ -4,7 +4,6 @@ expression: rows
|
||||
---
|
||||
|
||||
voice ● listening /voice mute /voice stop
|
||||
mic ▁▃▄▅▆█ codex █▆▅▄▃▁
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Renders compact voice controls and caller-owned microphone/speaker sample histories.
|
||||
//! Keep control positions stable and show mute only when the current phase permits it.
|
||||
//! Reduced motion hides the sample-history row while preserving voice controls.
|
||||
|
||||
use crate::key_hint::ShortcutHint;
|
||||
use crate::motion::MotionMode;
|
||||
@@ -72,7 +73,11 @@ pub(super) fn loading_glyph(started_at: Instant, mode: MotionMode) -> &'static s
|
||||
|
||||
impl Renderable for VoiceStrip {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if width == 0 { 0 } else { 2 }
|
||||
if width == 0 {
|
||||
0
|
||||
} else {
|
||||
1 + u16::from(self.state.animations)
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -149,7 +154,7 @@ impl Renderable for VoiceStrip {
|
||||
status.spans.push(controls.dim());
|
||||
}
|
||||
Paragraph::new(status).render(Rect::new(area.x, area.y, area.width, /*height*/ 1), buf);
|
||||
if area.height < 2 {
|
||||
if !self.state.animations || area.height < 2 {
|
||||
return;
|
||||
}
|
||||
let meter_width = available
|
||||
|
||||
@@ -48,6 +48,7 @@ fn rows(strip: &VoiceStrip, width: u16) -> (String, Buffer) {
|
||||
#[test]
|
||||
fn active_dashboard_preserves_live_colors_controls_and_width() {
|
||||
let mut strip = active_strip();
|
||||
strip.state.progress = false;
|
||||
let (actual, buffer) = rows(&strip, /*width*/ 47);
|
||||
assert_eq!(buffer[(45, 0)].symbol(), "p");
|
||||
assert_eq!(buffer[(46, 0)].symbol(), " ");
|
||||
@@ -95,6 +96,7 @@ fn connecting_and_muted_indicators_follow_actual_capture() {
|
||||
assert_eq!(buffer[(7, 0)].symbol(), "◌");
|
||||
assert_eq!(buffer[(7, 0)].fg, Color::Red);
|
||||
strip.state.phase = VoiceStripPhase::Active;
|
||||
strip.state.animations = true;
|
||||
strip.state.activity = "listening";
|
||||
strip.state.microphone_history = vec![255];
|
||||
let (reduced, _) = rows(&strip, /*width*/ 45);
|
||||
|
||||
@@ -1297,6 +1297,20 @@ impl ChatWidget {
|
||||
.transcript_role
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
// Keep recognition and interruption state current without echoing each partial utterance.
|
||||
// The final event commits the ordinary user history cell once.
|
||||
if role == "user" && !self.local_settings.tui.animations {
|
||||
if self
|
||||
.realtime_conversation
|
||||
.live_transcript_cell
|
||||
.take()
|
||||
.is_some()
|
||||
{
|
||||
self.bump_active_cell_revision();
|
||||
self.request_redraw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let previous = self
|
||||
.realtime_conversation
|
||||
.live_transcript_cell
|
||||
|
||||
@@ -189,10 +189,12 @@ impl ChatWidget {
|
||||
let speaker_intensity = audio_meter_intensity(speaker_peak);
|
||||
let previous_microphone_history = self.realtime_conversation.microphone_history;
|
||||
let previous_speaker_history = self.realtime_conversation.speaker_history;
|
||||
let mut changed = self.realtime_conversation.microphone_level != microphone_level
|
||||
|| self.realtime_conversation.speaker_level != speaker_level
|
||||
|| self.realtime_conversation.microphone_intensity != microphone_intensity
|
||||
|| self.realtime_conversation.speaker_intensity != speaker_intensity;
|
||||
let mut changed = (self.realtime_conversation.speaker_level > 0) != (speaker_level > 0)
|
||||
|| (self.local_settings.tui.animations
|
||||
&& (self.realtime_conversation.microphone_level != microphone_level
|
||||
|| self.realtime_conversation.speaker_level != speaker_level
|
||||
|| self.realtime_conversation.microphone_intensity != microphone_intensity
|
||||
|| self.realtime_conversation.speaker_intensity != speaker_intensity));
|
||||
if speaker_level > 0 {
|
||||
self.realtime_conversation.speaker_active_until = Some(now + SPEAKER_ACTIVITY_HOLD);
|
||||
} else {
|
||||
@@ -228,9 +230,10 @@ impl ChatWidget {
|
||||
self.realtime_conversation
|
||||
.audio_meter_history
|
||||
.push_back((microphone_intensity, speaker_intensity));
|
||||
changed |= previous_microphone_history != self.realtime_conversation.microphone_history
|
||||
|| previous_speaker_history != self.realtime_conversation.speaker_history
|
||||
|| had_meter_activity;
|
||||
changed |= self.local_settings.tui.animations
|
||||
&& (previous_microphone_history != self.realtime_conversation.microphone_history
|
||||
|| previous_speaker_history != self.realtime_conversation.speaker_history
|
||||
|| had_meter_activity);
|
||||
if changed {
|
||||
self.update_realtime_footer();
|
||||
}
|
||||
|
||||
@@ -665,6 +665,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() {
|
||||
|
||||
insta::assert_snapshot!(layouts.join("\n\n"), @r"
|
||||
5 rows:
|
||||
voice ● listening ctrl+x mute /voice stop
|
||||
› typed
|
||||
|
||||
6 rows:
|
||||
@@ -680,7 +681,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() {
|
||||
#[tokio::test]
|
||||
async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_muted() {
|
||||
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.local_settings.tui.animations = false;
|
||||
chat.local_settings.tui.animations = true;
|
||||
let thread_id = activate_voice(&mut chat);
|
||||
chat.realtime_conversation.audio_meter_history = [
|
||||
(0, 255),
|
||||
|
||||
-1
@@ -4,7 +4,6 @@ expression: "states.join(\"\\n\\n\")"
|
||||
---
|
||||
connecting:
|
||||
voice ◌ connecting /voice stop
|
||||
mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
|
||||
@@ -340,19 +340,7 @@ async fn restored_partial_caption_accepts_late_completion_without_duplicate_hist
|
||||
before_turn_id: None,
|
||||
},
|
||||
]));
|
||||
let live = chat
|
||||
.realtime_conversation
|
||||
.live_transcript_cell
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
insta::assert_snapshot!(
|
||||
"restored_voice_partial_live",
|
||||
live.display_lines(/*width*/ 80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
assert!(chat.realtime_conversation.live_transcript_cell.is_none());
|
||||
chat.on_realtime_transcript_delta("user".into(), "words".into());
|
||||
let retained = chat.take_realtime_transcript_cells_for_replay();
|
||||
assert_eq!(retained.len(), 1);
|
||||
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/realtime_tests/caption_replay.rs
|
||||
assertion_line: 2528
|
||||
expression: "live.display_lines(80).into_iter().map(|line|\nline.to_string()).collect::<Vec<_>>().join(\"\\n\")"
|
||||
---
|
||||
|
||||
› last
|
||||
@@ -193,7 +193,7 @@ async fn transcript_deltas_track_the_current_speaker() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_transcripts_stream_in_the_conversation_instead_of_the_footer() {
|
||||
async fn reduced_motion_keeps_user_transcripts_hidden_until_finalized() {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.local_settings.tui.animations = false;
|
||||
activate_voice(&mut chat);
|
||||
@@ -202,21 +202,8 @@ async fn voice_transcripts_stream_in_the_conversation_instead_of_the_footer() {
|
||||
chat.on_realtime_transcript_delta("user".to_string(), "pick a ".to_string());
|
||||
chat.on_realtime_transcript_delta("user".to_string(), "number".to_string());
|
||||
|
||||
let live = chat
|
||||
.active_cell_transcript_lines(/*width*/ 80)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(live.contains("pick a number"));
|
||||
let footer = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(footer.contains("voice ● listening"));
|
||||
let status_line = footer
|
||||
.lines()
|
||||
.find(|line| line.contains("voice ● listening"))
|
||||
.unwrap_or_default();
|
||||
assert!(!status_line.contains("pick a number"));
|
||||
assert!(chat.active_cell_transcript_lines(/*width*/ 80).is_none());
|
||||
assert_eq!(chat.realtime_conversation.transcript, "pick a number");
|
||||
assert!(events.try_recv().is_err());
|
||||
|
||||
chat.on_realtime_transcript_done("user".to_string(), "pick a number".to_string());
|
||||
@@ -770,15 +757,8 @@ async fn spoken_user_transcript_preserves_red_chevron_and_canonical_history() {
|
||||
);
|
||||
chat.local_settings.tui.animations = false;
|
||||
chat.on_realtime_transcript_delta("user".to_string(), " world".to_string());
|
||||
assert!(
|
||||
chat.realtime_conversation
|
||||
.live_transcript_cell
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.display_lines(/*width*/ 32)
|
||||
.iter()
|
||||
.any(|line| line.to_string() == "› hello world")
|
||||
);
|
||||
assert!(chat.realtime_conversation.live_transcript_cell.is_none());
|
||||
assert_eq!(chat.realtime_conversation.transcript, " hello world");
|
||||
chat.on_realtime_transcript_done("user".to_string(), " hello world".to_string());
|
||||
commit_realtime_history_events(&mut chat, &mut events);
|
||||
let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else {
|
||||
@@ -959,12 +939,8 @@ async fn empty_interleaved_caption_completion_invalidates_overlay() {
|
||||
chat.realtime_conversation.phase = phase;
|
||||
let previous_key = chat.active_cell_transcript_key().unwrap();
|
||||
chat.on_realtime_transcript_done("assistant".into(), String::new());
|
||||
let current_key = chat.active_cell_transcript_key().unwrap();
|
||||
assert_ne!(previous_key, current_key);
|
||||
let visible = chat.active_cell_transcript_lines(/*width*/ 80).unwrap();
|
||||
assert_eq!(
|
||||
visible.iter().map(ToString::to_string).collect::<Vec<_>>(),
|
||||
vec!["", "› Keep this caption", ""]
|
||||
);
|
||||
assert_ne!(Some(previous_key), chat.active_cell_transcript_key());
|
||||
assert!(chat.active_cell_transcript_lines(/*width*/ 80).is_none());
|
||||
assert_eq!(chat.realtime_conversation.transcript, "Keep this caption");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ async fn owned_live_history_keeps_all_sources_in_each_presentation() {
|
||||
]);
|
||||
widget.on_realtime_transcript_delta("assistant".into(), "assistant ".into());
|
||||
widget.on_realtime_transcript_delta("user".into(), "user caption".into());
|
||||
widget.on_realtime_transcript_done("user".into(), "user caption".into());
|
||||
widget.on_realtime_transcript_delta("assistant".into(), "caption".into());
|
||||
let expected = [
|
||||
"active",
|
||||
|
||||
-2
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
assertion_line: 34
|
||||
expression: rendered
|
||||
---
|
||||
" "
|
||||
@@ -12,7 +11,6 @@ expression: rendered
|
||||
" "
|
||||
" "
|
||||
" voice ● listening ctrl+x mute /voice stop "
|
||||
" mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁ "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: normalized_backend_snapshot(terminal.backend())
|
||||
---
|
||||
" "
|
||||
"• Agent answer arriving "
|
||||
" "
|
||||
" "
|
||||
" voice ● listening ctrl+x mute /voice stop "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" GPT-5.6-Sol default · /tmp/project "
|
||||
@@ -13,7 +13,7 @@ use ratatui::backend::TestBackend;
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_live_transcript_renders_beside_the_streamed_cell() {
|
||||
async fn finalized_voice_transcript_renders_beside_the_streamed_cell() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.local_settings.tui.animations = false;
|
||||
activate_voice_for_thread(&mut chat, ThreadId::new());
|
||||
@@ -23,17 +23,21 @@ async fn voice_live_transcript_renders_beside_the_streamed_cell() {
|
||||
/*is_first_line*/ true,
|
||||
)));
|
||||
chat.on_realtime_transcript_delta("user".into(), "pick a number".into());
|
||||
|
||||
let width = 60;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render live voice transcript");
|
||||
let rendered = normalized_backend_snapshot(terminal.backend());
|
||||
assert!(rendered.contains("Agent answer arriving"), "{rendered}");
|
||||
assert!(rendered.contains("pick a number"), "{rendered}");
|
||||
assert_chatwidget_snapshot!("voice_live_transcript_and_stream", rendered);
|
||||
for (finalized, snapshot) in [
|
||||
(false, "voice_partial_transcript_hidden"),
|
||||
(true, "voice_live_transcript_and_stream"),
|
||||
] {
|
||||
if finalized {
|
||||
chat.on_realtime_transcript_done("user".into(), "pick a number".into());
|
||||
}
|
||||
let width = 60;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("render voice transcript");
|
||||
assert_chatwidget_snapshot!(snapshot, normalized_backend_snapshot(terminal.backend()));
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_test_ambient_pet(chat: &mut ChatWidget) {
|
||||
|
||||
Reference in New Issue
Block a user