Compare commits

...

3 Commits

Author SHA1 Message Date
Ahmed Ibrahim
576dc290a8 Merge branch 'main' into dev/realtime-ctrl-c-close-first 2026-03-07 19:13:35 -08:00
Ahmed Ibrahim
568de22b6f codex: fix CI failure on PR #13820 2026-03-07 12:12:59 -08:00
Ahmed Ibrahim
31604b4afd Close realtime before interrupting on ctrl+c 2026-03-06 17:29:55 -08:00
5 changed files with 262 additions and 69 deletions

View File

@@ -3769,6 +3769,10 @@ impl ChatComposer {
self.status_line_enabled = enabled;
true
}
pub(crate) fn transcription_placeholder_exists(&self, id: &str) -> bool {
self.textarea.named_element_range(id).is_some()
}
}
#[cfg(not(target_os = "linux"))]

View File

@@ -597,6 +597,10 @@ impl BottomPane {
self.composer.pending_pastes()
}
pub(crate) fn transcription_placeholder_exists(&self, id: &str) -> bool {
self.composer.transcription_placeholder_exists(id)
}
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.composer.apply_external_edit(text);
self.request_redraw();

View File

@@ -3667,80 +3667,78 @@ impl ChatWidget {
{
self.cycle_collaboration_mode();
}
_ => match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted {
text,
text_elements,
} => {
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
_ => {
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
} => {
let local_images = self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
if user_message.text.is_empty()
&& user_message.local_images.is_empty()
&& user_message.remote_image_urls.is_empty()
{
return;
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
if !(user_message.text.is_empty()
&& user_message.local_images.is_empty()
&& user_message.remote_image_urls.is_empty())
&& let Some(user_message) =
self.maybe_defer_user_message_for_realtime(user_message)
{
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
if should_submit_now {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
}
let Some(user_message) =
self.maybe_defer_user_message_for_realtime(user_message)
else {
return;
};
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
if should_submit_now {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
InputResult::Queued {
text,
text_elements,
} => {
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
InputResult::Queued {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
} => {
let local_images = self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
let Some(user_message) =
self.maybe_defer_user_message_for_realtime(user_message)
else {
return;
};
self.queue_user_message(user_message);
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
if let Some(user_message) =
self.maybe_defer_user_message_for_realtime(user_message)
{
self.queue_user_message(user_message);
}
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.dispatch_command_with_args(cmd, args, text_elements);
}
InputResult::None => {}
}
InputResult::Command(cmd) => {
self.dispatch_command(cmd);
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.dispatch_command_with_args(cmd, args, text_elements);
}
InputResult::None => {}
},
self.maybe_request_realtime_close_after_composer_mutation();
}
}
}
@@ -3767,6 +3765,7 @@ impl ChatWidget {
pub(crate) fn apply_external_edit(&mut self, text: String) {
self.bottom_pane.apply_external_edit(text);
self.maybe_request_realtime_close_after_composer_mutation();
self.request_redraw();
}
@@ -7909,15 +7908,21 @@ impl ChatWidget {
/// Handles a Ctrl+C press at the chat-widget layer.
///
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
/// is armed.
/// pane. When realtime voice mode is live, the first press closes realtime instead of clearing
/// the composer or arming quit. If cancellable work is active, Ctrl+C also submits
/// `Op::Interrupt` after the shortcut is armed.
///
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
/// quit.
fn on_ctrl_c(&mut self) {
let key = key_hint::ctrl(KeyCode::Char('c'));
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
let cancellation_event = if modal_or_popup_active {
self.bottom_pane.on_ctrl_c()
} else {
CancellationEvent::NotHandled
};
if cancellation_event == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if modal_or_popup_active {
self.quit_shortcut_expires_at = None;
@@ -7930,6 +7935,29 @@ impl ChatWidget {
return;
}
if self.realtime_conversation.is_live() {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
if !self.realtime_conversation.close_requested() {
self.request_realtime_conversation_close(None);
return;
}
if self.is_cancellable_work_active() {
self.submit_op(Op::Interrupt);
return;
}
}
if !modal_or_popup_active && self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
if DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
self.arm_quit_shortcut(key);
}
return;
}
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
if self.is_cancellable_work_active() {
self.submit_op(Op::Interrupt);

View File

@@ -45,6 +45,10 @@ impl RealtimeConversationUiState {
pub(super) fn is_active(&self) -> bool {
matches!(self.phase, RealtimeConversationPhase::Active)
}
pub(super) fn close_requested(&self) -> bool {
self.requested_close
}
}
#[derive(Clone, Debug, PartialEq)]
@@ -230,6 +234,46 @@ impl ChatWidget {
}
}
#[cfg(test)]
pub(super) fn set_realtime_conversation_state(
&mut self,
phase: RealtimeConversationPhase,
requested_close: bool,
meter_placeholder_id: Option<String>,
) {
self.realtime_conversation.phase = phase;
self.realtime_conversation.requested_close = requested_close;
self.realtime_conversation.meter_placeholder_id = meter_placeholder_id;
}
#[cfg(test)]
pub(super) fn realtime_conversation_phase(&self) -> RealtimeConversationPhase {
self.realtime_conversation.phase
}
#[cfg(test)]
pub(super) fn realtime_close_requested(&self) -> bool {
self.realtime_conversation.requested_close
}
pub(super) fn maybe_request_realtime_close_after_composer_mutation(&mut self) {
if !self.realtime_conversation.is_live() || self.realtime_conversation.requested_close {
return;
}
let Some(meter_placeholder_id) = self.realtime_conversation.meter_placeholder_id.as_deref()
else {
return;
};
if !self
.bottom_pane
.transcription_placeholder_exists(meter_placeholder_id)
{
self.request_realtime_conversation_close(None);
}
}
pub(super) fn reset_realtime_conversation_state(&mut self) {
self.stop_realtime_local_audio();
self.set_footer_hint_override(None);

View File

@@ -4,6 +4,7 @@
//! the TUI output. Many assertions are snapshot-based so that layout regressions and status/header
//! changes show up as stable, reviewable diffs.
use super::realtime::RealtimeConversationPhase;
use super::*;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
@@ -1922,6 +1923,14 @@ fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
}
}
fn drain_ops(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Vec<Op> {
let mut ops = Vec::new();
while let Ok(op) = op_rx.try_recv() {
ops.push(op);
}
ops
}
fn set_chatgpt_auth(chat: &mut ChatWidget) {
chat.auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
@@ -4546,6 +4555,110 @@ async fn ctrl_c_shutdown_works_with_caps_lock() {
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn ctrl_c_in_live_realtime_requests_close_before_interrupting() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, true);
chat.bottom_pane
.set_composer_text("draft message".to_string(), Vec::new(), Vec::new());
chat.set_realtime_conversation_state(RealtimeConversationPhase::Active, false, None);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(chat.bottom_pane.composer_text(), "draft message");
assert_eq!(
chat.realtime_conversation_phase(),
RealtimeConversationPhase::Stopping
);
assert!(chat.realtime_close_requested());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
assert_eq!(drain_ops(&mut op_rx), vec![Op::RealtimeConversationClose]);
}
#[tokio::test]
async fn second_ctrl_c_while_realtime_is_stopping_interrupts_cancellable_work() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, true);
chat.bottom_pane
.set_composer_text("draft message".to_string(), Vec::new(), Vec::new());
chat.bottom_pane.set_task_running(true);
chat.set_realtime_conversation_state(RealtimeConversationPhase::Stopping, true, None);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(chat.bottom_pane.composer_text(), "draft message");
assert_eq!(
chat.realtime_conversation_phase(),
RealtimeConversationPhase::Stopping
);
assert!(chat.realtime_close_requested());
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
assert_eq!(drain_ops(&mut op_rx), vec![Op::Interrupt]);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn deleting_realtime_meter_placeholder_requests_close() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, true);
let meter_placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
chat.set_realtime_conversation_state(
RealtimeConversationPhase::Active,
false,
Some(meter_placeholder_id.clone()),
);
assert!(
chat.bottom_pane
.transcription_placeholder_exists(&meter_placeholder_id)
);
chat.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(
!chat
.bottom_pane
.transcription_placeholder_exists(&meter_placeholder_id)
);
assert_eq!(
chat.realtime_conversation_phase(),
RealtimeConversationPhase::Stopping
);
assert!(chat.realtime_close_requested());
assert_eq!(drain_ops(&mut op_rx), vec![Op::RealtimeConversationClose]);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn external_edit_removing_realtime_meter_requests_close() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.set_feature_enabled(Feature::RealtimeConversation, true);
let meter_placeholder_id = chat.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
chat.set_realtime_conversation_state(
RealtimeConversationPhase::Active,
false,
Some(meter_placeholder_id.clone()),
);
assert!(
chat.bottom_pane
.transcription_placeholder_exists(&meter_placeholder_id)
);
chat.apply_external_edit("rewritten draft".to_string());
assert_eq!(chat.bottom_pane.composer_text(), "rewritten draft");
assert!(
!chat
.bottom_pane
.transcription_placeholder_exists(&meter_placeholder_id)
);
assert_eq!(
chat.realtime_conversation_phase(),
RealtimeConversationPhase::Stopping
);
assert!(chat.realtime_close_requested());
assert_eq!(drain_ops(&mut op_rx), vec![Op::RealtimeConversationClose]);
}
#[tokio::test]
async fn ctrl_d_quits_without_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;