mirror of
https://github.com/openai/codex.git
synced 2026-03-13 02:03:59 +00:00
Compare commits
3 Commits
dev/cc/mul
...
dev/realti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576dc290a8 | ||
|
|
568de22b6f | ||
|
|
31604b4afd |
@@ -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"))]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user