mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Support Cloud image messages and editable draft handoffs (#60061)
## Why Cloud messages previously accepted only plain text, and switching a draft to Cloud waited for session preparation without processing composer input. ## What changed - Support ordered text and image parts in Cloud submissions and user-message summaries, preserving attachment placeholders, image detail, and client message IDs. - Accept inline data URLs and uploaded file IDs, including image-only messages. Reject invalid references and text spans locally, and cap encoded image requests at 3.75 MiB while retaining the existing plain-text limit. - Keep the composer editable and redraw the TUI while Cloud prepares a conversation. Carry the latest draft, attachments, and Vim mode into the destination, retain drafts on failure, and prevent submission to the outgoing session during the handoff. - Preserve buffered paste input and pending clipboard reads across the handoff, with connection and failure hints. ## Testing Add regression coverage for image wire encoding, receipt identity, validation and size boundaries, draft editing during successful and failed handoffs, clipboard completion, Vim state, and attachment recovery after rejected submissions. GitOrigin-RevId: 0cbcde0ab89ba961780b5f88456057e6b6a19cd5
This commit is contained in:
committed by
copyberry
parent
d4a341bd52
commit
e6bf88ae02
@@ -2,11 +2,15 @@
|
||||
|
||||
use super::backend_sessions::BackendSessions;
|
||||
use super::backend_sessions::SessionSlot;
|
||||
use super::cloud_switch_wait::ClipboardHandoff;
|
||||
use super::cloud_switch_wait::finish_failed_cloud_switch;
|
||||
use super::cloud_switch_wait::wait_for_cloud_switch;
|
||||
use super::resume_preparation::ResumeSource;
|
||||
use super::session_lifecycle::PreparedFreshSession;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ThreadItemPayload;
|
||||
use futures::TryFutureExt;
|
||||
|
||||
pub(super) enum NewBackendSession {
|
||||
Fresh { name: Option<String> },
|
||||
@@ -160,18 +164,31 @@ impl App {
|
||||
None
|
||||
};
|
||||
let user_config_path = self.local_settings.user_config_path.clone();
|
||||
let (name, draft) = match session {
|
||||
let responsive_cloud_switch = settings.target == AppServerTarget::Cloud
|
||||
&& matches!(session, NewBackendSession::CarryDraft);
|
||||
let (name, mut draft) = match session {
|
||||
NewBackendSession::Fresh { name } => (name, None),
|
||||
NewBackendSession::CarryDraft => (
|
||||
self.chat_widget.thread_name(),
|
||||
Some(self.chat_widget.composer_draft_snapshot()),
|
||||
(!responsive_cloud_switch).then(|| self.chat_widget.composer_draft_snapshot()),
|
||||
),
|
||||
};
|
||||
let destination = backends.session_at_mut(slot);
|
||||
let clipboard_after_bootstrap = if destination.remote_cwd_override().is_some() {
|
||||
// An explicit remote directory can open the trust prompt after bootstrap.
|
||||
ClipboardHandoff::SettleBeforeNextView
|
||||
} else {
|
||||
ClipboardHandoff::ContinueEditing
|
||||
};
|
||||
let bootstrap = destination
|
||||
.bootstrap_for_new_thread(&settings.config)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to prepare the new conversation: {error}"))?;
|
||||
.map_err(|error| format!("Failed to prepare the new conversation: {error}"));
|
||||
let bootstrap = if responsive_cloud_switch {
|
||||
let events = tui.event_stream();
|
||||
wait_for_cloud_switch(self, tui, bootstrap, events, clipboard_after_bootstrap).await
|
||||
} else {
|
||||
bootstrap.await
|
||||
}?;
|
||||
let destination_cwd = settings.config.cwd.to_path_buf();
|
||||
if let Err(control) = self
|
||||
.confirm_directory_trust_for_target(
|
||||
@@ -190,29 +207,59 @@ impl App {
|
||||
)
|
||||
.await
|
||||
{
|
||||
if responsive_cloud_switch {
|
||||
let events = tui.event_stream();
|
||||
finish_failed_cloud_switch(self, tui, events).await;
|
||||
}
|
||||
if matches!(control, AppRunControl::Exit(_)) {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (config, local_settings) = self
|
||||
.load_new_session_config_from(destination, &settings, destination_cwd)
|
||||
.load_new_session_config_from(
|
||||
destination,
|
||||
&settings,
|
||||
destination_cwd,
|
||||
responsive_cloud_switch.then_some(&mut *tui),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to read new session defaults: {error}"))?;
|
||||
let started = destination
|
||||
let host_project_cwd = self.backend_preference_context.local_cwd.clone();
|
||||
let start = destination
|
||||
.start_thread_with_session_start_source(
|
||||
&local_settings,
|
||||
&config,
|
||||
/*session_start_source*/ None,
|
||||
/*remote_cwd_override*/ None,
|
||||
/*selected_profile*/ None,
|
||||
self.backend_preference_context.local_cwd.as_deref(),
|
||||
host_project_cwd.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("Failed to start the new conversation: {error}"));
|
||||
let started = if responsive_cloud_switch {
|
||||
let events = tui.event_stream();
|
||||
wait_for_cloud_switch(
|
||||
self,
|
||||
tui,
|
||||
start,
|
||||
events,
|
||||
ClipboardHandoff::SettleBeforeNextView,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("Failed to start the new conversation: {error}"))?;
|
||||
} else {
|
||||
start.await
|
||||
}?;
|
||||
if responsive_cloud_switch {
|
||||
draft = Some(self.chat_widget.composer_draft_snapshot());
|
||||
}
|
||||
// Clear before switching so a terminal error leaves the outgoing conversation usable.
|
||||
tui.clear_for_thread_switch()
|
||||
.map_err(|error| format!("Failed to open the new conversation: {error}"))?;
|
||||
if let Err(error) = tui.clear_for_thread_switch() {
|
||||
if responsive_cloud_switch {
|
||||
let events = tui.event_stream();
|
||||
finish_failed_cloud_switch(self, tui, events).await;
|
||||
}
|
||||
return Err(format!("Failed to open the new conversation: {error}"));
|
||||
}
|
||||
let destination_thread = started.session.thread_id;
|
||||
self.switch_backend_context(
|
||||
backends,
|
||||
@@ -250,7 +297,11 @@ impl App {
|
||||
self.local_settings.tui.notification_settings.condition,
|
||||
);
|
||||
if let Some(draft) = draft {
|
||||
self.chat_widget.restore_startup_draft(draft);
|
||||
if responsive_cloud_switch {
|
||||
self.chat_widget.restore_cloud_switch_draft(draft);
|
||||
} else {
|
||||
self.chat_widget.restore_startup_draft(draft);
|
||||
}
|
||||
if let Some(thread_id) = displayed_thread
|
||||
&& self.current_displayed_thread_id() == Some(destination_thread)
|
||||
&& self
|
||||
|
||||
@@ -20,7 +20,10 @@ impl App {
|
||||
.copy(text, CopyFormat::Markdown, tui.frame_requester());
|
||||
self.chat_widget.show_copy_result("last message", result);
|
||||
}
|
||||
KeyEventAction::PasteImage { text_fallback } => {
|
||||
KeyEventAction::PasteImage {
|
||||
text_fallback,
|
||||
context,
|
||||
} => {
|
||||
if text_fallback.is_some()
|
||||
&& self
|
||||
.right_click_paste_target(tui, PasteSource::Clipboard, text_fallback)
|
||||
@@ -33,7 +36,7 @@ impl App {
|
||||
self.chat_widget
|
||||
.add_info_message("Clipboard busy; try again".into(), /*hint*/ None);
|
||||
} else {
|
||||
match self.chat_widget.paste_image() {
|
||||
match self.chat_widget.paste_image(context, text_fallback) {
|
||||
Ok(()) => {}
|
||||
Err(PasteImageError::NoImage(_)) if text_fallback.is_some() => {
|
||||
self.start_clipboard_text_paste(
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Keeps the composer editable while a Cloud backend switch owns navigation.
|
||||
//!
|
||||
//! Handles editing and redraws. The original handoff owns
|
||||
//! session preparation and adoption, so no competing navigation can install a Cloud stream.
|
||||
|
||||
use super::App;
|
||||
use crate::bottom_pane::RestrictedInputMode;
|
||||
use crate::chatwidget::ClipboardPasteContext;
|
||||
use crate::tui;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::layout::Size;
|
||||
use std::future::Future;
|
||||
use tokio_stream::Stream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ClipboardHandoff {
|
||||
ContinueEditing,
|
||||
SettleBeforeNextView,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CloudSwitchProgress {
|
||||
Connecting(ClipboardHandoff),
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Process editor and repaint events while a Cloud switch owns navigation.
|
||||
fn handle_cloud_switch_event(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
event: tui::TuiEvent,
|
||||
progress: CloudSwitchProgress,
|
||||
) {
|
||||
let screen_size = match tui.screen_size_for_event(&event) {
|
||||
Ok(size) => size,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to inspect terminal size during cloud switch");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Clipboard text is delivered on Draw after ordinary input invalidation and target checks.
|
||||
app.invalidate_right_click_paste(&event);
|
||||
// Polling retains copy feedback for the ordinary event loop.
|
||||
tui.clipboard.poll();
|
||||
let event = app.finish_right_click_paste(tui, event);
|
||||
if app.pending_right_click_paste.is_some() {
|
||||
if matches!(event, tui::TuiEvent::Draw) {
|
||||
// A PRIMARY read may be waiting for an earlier copy or publication.
|
||||
tui.clipboard.advance(tui.frame_requester());
|
||||
} else if matches!(event, tui::TuiEvent::Resize(_)) {
|
||||
// A size change can consume the worker's completion frame.
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
match event {
|
||||
tui::TuiEvent::Key(mut key) => {
|
||||
let escape = if app.should_recover_vim_insert_escape(tui, key)
|
||||
&& !(tui.is_owned_screen()
|
||||
&& crate::transcript_view::JumpTarget::from_key(key).is_some())
|
||||
{
|
||||
key.modifiers.remove(KeyModifiers::ALT);
|
||||
Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for key in escape.into_iter().chain([key]) {
|
||||
if let Some(key) = app.route_key_chord_event(tui, key) {
|
||||
let action = app
|
||||
.chat_widget
|
||||
.handle_restricted_key(key, RestrictedInputMode::DraftHandoff);
|
||||
app.handle_clipboard_key_action(tui, action);
|
||||
}
|
||||
}
|
||||
show_cloud_switch_hint(app, progress);
|
||||
}
|
||||
tui::TuiEvent::Paste(text) => {
|
||||
app.cancel_pending_key_chord();
|
||||
app.chat_widget
|
||||
.handle_paste(text.replace("\r\n", "\n").replace('\r', "\n"));
|
||||
show_cloud_switch_hint(app, progress);
|
||||
}
|
||||
tui::TuiEvent::Draw
|
||||
| tui::TuiEvent::Resize(_)
|
||||
| tui::TuiEvent::Resume
|
||||
| tui::TuiEvent::FocusGained => {
|
||||
if app
|
||||
.chat_widget
|
||||
.handle_handoff_paste_tick(tui.frame_requester())
|
||||
{
|
||||
tui.defer_screen_size(screen_size);
|
||||
return;
|
||||
}
|
||||
app.chat_widget.pre_draw_tick();
|
||||
draw_cloud_switch(app, tui, screen_size);
|
||||
}
|
||||
tui::TuiEvent::FocusLost => {
|
||||
app.cancel_pending_key_chord();
|
||||
show_cloud_switch_hint(app, progress);
|
||||
}
|
||||
tui::TuiEvent::Mouse(mouse) => {
|
||||
app.start_right_click_paste(tui, mouse, ClipboardPasteContext::DraftHandoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_cloud_switch_hint(app: &mut App, progress: CloudSwitchProgress) {
|
||||
let (label, action) = match progress {
|
||||
CloudSwitchProgress::Connecting(_) => ("Connecting to Cloud…", "submit when ready"),
|
||||
CloudSwitchProgress::Failed => ("Cloud conversation did not open", "draft kept"),
|
||||
};
|
||||
app.chat_widget
|
||||
.set_footer_hint_override(Some(vec![(label.into(), action.into())]));
|
||||
}
|
||||
|
||||
fn draw_cloud_switch(app: &mut App, tui: &mut tui::Tui, screen_size: Size) {
|
||||
if let Err(error) = app.render_chat_widget_frame(tui, screen_size) {
|
||||
tracing::warn!(%error, "failed to render during cloud switch");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep input restricted through the failure frame and any input that arrived during its paint.
|
||||
pub(super) async fn finish_failed_cloud_switch(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
mut events: impl Stream<Item = tui::TuiEvent> + Unpin,
|
||||
) {
|
||||
let progress = CloudSwitchProgress::Failed;
|
||||
show_cloud_switch_hint(app, progress);
|
||||
draw_cloud_switch(app, tui, tui.terminal.last_known_screen_size);
|
||||
drain_cloud_switch_events(app, tui, &mut events, progress).await;
|
||||
}
|
||||
|
||||
async fn drain_cloud_switch_events(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
events: &mut (impl Stream<Item = tui::TuiEvent> + Unpin),
|
||||
progress: CloudSwitchProgress,
|
||||
) {
|
||||
// The current draft owns paste keys and any clipboard text until adoption.
|
||||
let settle_clipboard = matches!(
|
||||
progress,
|
||||
CloudSwitchProgress::Connecting(ClipboardHandoff::SettleBeforeNextView)
|
||||
);
|
||||
if settle_clipboard && app.pending_right_click_paste.is_some() {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
loop {
|
||||
let clipboard_deadline = if settle_clipboard {
|
||||
app.pending_right_click_paste
|
||||
.as_ref()
|
||||
.and_then(|pending| tui.clipboard.text_read_deadline(&pending.request))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let deadline = app
|
||||
.chat_widget
|
||||
.composer_paste_deadline()
|
||||
.max(clipboard_deadline)
|
||||
.map(tokio::time::Instant::from_std)
|
||||
.unwrap_or_else(tokio::time::Instant::now);
|
||||
let Ok(Some(event)) = tokio::time::timeout_at(deadline, events.next()).await else {
|
||||
if clipboard_deadline.is_some() {
|
||||
// The terminal stream can end or the deadline can beat its scheduled Draw.
|
||||
handle_cloud_switch_event(app, tui, tui::TuiEvent::Draw, progress);
|
||||
app.pending_right_click_paste = None;
|
||||
}
|
||||
break;
|
||||
};
|
||||
handle_cloud_switch_event(app, tui, event, progress);
|
||||
}
|
||||
app.cancel_pending_key_chord();
|
||||
app.chat_widget.set_footer_hint_override(/*items*/ None);
|
||||
}
|
||||
|
||||
/// Keep the draft editable while Cloud prepares its destination. Other app actions wait
|
||||
/// until the handoff completes, so a second navigation cannot replace the pending stream.
|
||||
pub(crate) async fn wait_for_cloud_switch<T, E>(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
operation: impl Future<Output = Result<T, E>>,
|
||||
events: impl Stream<Item = tui::TuiEvent> + Unpin,
|
||||
clipboard_handoff: ClipboardHandoff,
|
||||
) -> Result<T, E> {
|
||||
let progress = CloudSwitchProgress::Connecting(clipboard_handoff);
|
||||
app.cancel_pending_key_chord();
|
||||
show_cloud_switch_hint(app, progress);
|
||||
draw_cloud_switch(app, tui, tui.terminal.last_known_screen_size);
|
||||
if app.pending_right_click_paste.is_some() {
|
||||
// A completion frame can arrive between Cloud preparation phases.
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
let mut events = events.fuse();
|
||||
tokio::pin!(operation);
|
||||
let result = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut operation => break result,
|
||||
Some(event) = events.next() => handle_cloud_switch_event(app, tui, event, progress),
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
finish_failed_cloud_switch(app, tui, events).await;
|
||||
} else {
|
||||
drain_cloud_switch_events(app, tui, &mut events, progress).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -67,7 +67,7 @@ impl App {
|
||||
) -> Result<(Config, crate::local_settings::LocalSettings)> {
|
||||
let settings = self.backend_settings();
|
||||
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
|
||||
self.load_new_session_config_from(app_server, &settings, cwd)
|
||||
self.load_new_session_config_from(app_server, &settings, cwd, /*cloud_switch*/ None)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ impl App {
|
||||
app_server: &AppServerSession,
|
||||
settings: &BackendSettings,
|
||||
cwd: PathBuf,
|
||||
mut cloud_switch: Option<&mut tui::Tui>,
|
||||
) -> Result<(Config, crate::local_settings::LocalSettings)> {
|
||||
let defaults_cwd = match app_server.thread_params_mode() {
|
||||
crate::app_server_session::ThreadParamsMode::Embedded => cwd.as_path(),
|
||||
@@ -84,16 +85,30 @@ impl App {
|
||||
app_server.remote_cwd_override().unwrap_or(Path::new("."))
|
||||
}
|
||||
};
|
||||
let defaults = crate::config_update::read_effective_config_if_supported(
|
||||
app_server.request_handle(),
|
||||
defaults_cwd,
|
||||
)
|
||||
.await?;
|
||||
// Stage local preferences and permission carryover without changing the active task.
|
||||
let (mut config, local_settings) = match self
|
||||
.rebuild_config_for_cwd_from(&settings.config, cwd)
|
||||
let refresh = self.rebuild_config_for_cwd_from(&settings.config, cwd.clone());
|
||||
let load = async {
|
||||
let defaults = crate::config_update::read_effective_config_if_supported(
|
||||
app_server.request_handle(),
|
||||
defaults_cwd,
|
||||
)
|
||||
.await?;
|
||||
Ok::<_, color_eyre::eyre::Report>((defaults, refresh.await))
|
||||
};
|
||||
let (defaults, refreshed_config) = if let Some(tui) = cloud_switch.as_deref_mut() {
|
||||
let events = tui.event_stream();
|
||||
super::cloud_switch_wait::wait_for_cloud_switch(
|
||||
self,
|
||||
tui,
|
||||
load,
|
||||
events,
|
||||
super::cloud_switch_wait::ClipboardHandoff::ContinueEditing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
} else {
|
||||
load.await
|
||||
}?;
|
||||
// Stage local preferences and permission carryover without changing the active task.
|
||||
let (mut config, local_settings) = match refreshed_config {
|
||||
Ok(config) => {
|
||||
let local_settings = settings.local_settings.reloaded(&config);
|
||||
(config, local_settings)
|
||||
@@ -112,11 +127,17 @@ impl App {
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
self.apply_runtime_policy_overrides_with_reviewer(
|
||||
if let Err(error) = self.apply_runtime_policy_overrides_with_reviewer(
|
||||
&mut config,
|
||||
RuntimePolicyOverrideScope::ExplicitOnly,
|
||||
settings.config.approvals_reviewer,
|
||||
)?;
|
||||
) {
|
||||
if let Some(tui) = cloud_switch {
|
||||
let events = tui.event_stream();
|
||||
super::cloud_switch_wait::finish_failed_cloud_switch(self, tui, events).await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(defaults) = defaults.as_ref() {
|
||||
overlay_new_session_defaults(
|
||||
&mut config,
|
||||
|
||||
@@ -1762,6 +1762,7 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
ClipboardPasteContext::Composer,
|
||||
);
|
||||
assert!(!tui.clipboard.is_busy());
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
@@ -2051,11 +2052,14 @@ async fn composer_thumbnails_follow_draft_deletion_and_resize() -> Result<()> {
|
||||
tui.set_owned_screen(owned)?;
|
||||
app.chat_widget
|
||||
.set_pet_image_support_for_tests(PetImageSupport::Supported(ImageProtocol::Kitty));
|
||||
app.chat_widget.attach_image(portrait.clone());
|
||||
app.chat_widget
|
||||
.attach_image(portrait.clone(), ClipboardPasteContext::Composer);
|
||||
app.chat_widget.handle_key_event(KeyCode::Home.into());
|
||||
app.chat_widget.attach_image(banner.clone());
|
||||
app.chat_widget
|
||||
.attach_image(banner.clone(), ClipboardPasteContext::Composer);
|
||||
app.chat_widget.handle_key_event(KeyCode::End.into());
|
||||
app.chat_widget.attach_image(banner.clone());
|
||||
app.chat_widget
|
||||
.attach_image(banner.clone(), ClipboardPasteContext::Composer);
|
||||
|
||||
let narrow = Size::new(/*width*/ 20, /*height*/ 24);
|
||||
app.render_chat_widget_frame(&mut tui, narrow)?;
|
||||
|
||||
@@ -17,7 +17,7 @@ pub(super) struct PendingPaste {
|
||||
target: (Option<ThreadId>, crate::bottom_pane::ClipboardPasteTarget),
|
||||
source: PasteSource,
|
||||
shortcut: Option<KeyCode>,
|
||||
_request: Arc<()>,
|
||||
pub(super) request: Arc<()>,
|
||||
}
|
||||
|
||||
pub(crate) struct PasteEnvironment {
|
||||
@@ -113,7 +113,12 @@ impl App {
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn start_right_click_paste(&mut self, tui: &mut tui::Tui, mouse: MouseEvent) {
|
||||
pub(super) fn start_right_click_paste(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
mouse: MouseEvent,
|
||||
context: ClipboardPasteContext,
|
||||
) {
|
||||
let source = match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Right) => PasteSource::Clipboard,
|
||||
MouseEventKind::Down(MouseButton::Middle) => PasteSource::Primary,
|
||||
@@ -122,6 +127,16 @@ impl App {
|
||||
if !mouse.modifiers.is_empty() || self.pending_right_click_paste.is_some() {
|
||||
return;
|
||||
}
|
||||
// The restricted handoff does not run the ordinary mouse editor first. Settle any
|
||||
// held typing before taking the target of an eligible click.
|
||||
if context == ClipboardPasteContext::DraftHandoff
|
||||
&& (self
|
||||
.right_click_paste_target(tui, source, /*shortcut*/ None)
|
||||
.is_none()
|
||||
|| !self.chat_widget.prepare_composer_mouse(mouse))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.start_clipboard_text_paste(tui, source, /*shortcut*/ None);
|
||||
}
|
||||
|
||||
@@ -140,7 +155,7 @@ impl App {
|
||||
target,
|
||||
source,
|
||||
shortcut,
|
||||
_request: request,
|
||||
request,
|
||||
});
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ async fn pending_primary_paste_survives_release_but_not_other_input() {
|
||||
),
|
||||
source: PasteSource::Primary,
|
||||
shortcut: None,
|
||||
_request: Arc::new(()),
|
||||
request: Arc::new(()),
|
||||
});
|
||||
app.invalidate_right_click_paste(&TuiEvent::Mouse(MouseEvent {
|
||||
kind,
|
||||
|
||||
@@ -159,7 +159,7 @@ impl App {
|
||||
self.pending_thread_switch_resets != 0 && self.side_retention_enabled(tui)
|
||||
}
|
||||
|
||||
/// Drain a side pair's queued prompts and transcript updates before a key can switch focus.
|
||||
/// Apply queued app events before input can cross a backend or side-chat switch.
|
||||
pub(super) fn can_read_terminal_after_thread_switch(
|
||||
&self,
|
||||
tui: &tui::Tui,
|
||||
@@ -167,7 +167,8 @@ impl App {
|
||||
) -> bool {
|
||||
self.pending_thread_switch_resets == 0
|
||||
&& (app_event_rx.is_empty()
|
||||
|| (!self.side_retention_enabled(tui)
|
||||
|| (!self.config.features.enabled(Feature::TuiMultiBackend)
|
||||
&& !self.side_retention_enabled(tui)
|
||||
&& self
|
||||
.current_displayed_thread_id()
|
||||
.and_then(|id| self.side_pair_key(id))
|
||||
|
||||
@@ -12,6 +12,9 @@ use crate::test_support::boxed_future;
|
||||
use codex_models_manager::test_support::construct_model_info_offline_for_tests;
|
||||
use codex_models_manager::test_support::get_model_offline_for_tests;
|
||||
|
||||
pub(crate) use super::cloud_switch_wait::ClipboardHandoff;
|
||||
pub(crate) use super::cloud_switch_wait::wait_for_cloud_switch;
|
||||
|
||||
pub(super) fn enable_side_retention(app: &mut App) -> Result<()> {
|
||||
app.config.features.enable(Feature::TuiWorkspace)?;
|
||||
app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned;
|
||||
|
||||
@@ -1056,3 +1056,230 @@ async fn backend_cycling_waits_for_foreground_and_parked_voice_calls() -> Result
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cloud_switch_wait_keeps_draft_editable_without_submitting_to_local() -> Result<()> {
|
||||
use crate::app::cloud_switch_wait::ClipboardHandoff;
|
||||
use crate::app::cloud_switch_wait::wait_for_cloud_switch;
|
||||
|
||||
let (mut app, mut app_events, mut operations) = make_test_app_with_channels().await;
|
||||
let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf());
|
||||
app.chat_widget.handle_thread_session(session);
|
||||
app.chat_widget
|
||||
.set_new_backend_control(Some("Cloud".into()));
|
||||
app.chat_widget
|
||||
.apply_external_edit("/review first draft".into());
|
||||
app.keymap
|
||||
.composer
|
||||
.queue
|
||||
.push(crate::key_hint::plain(KeyCode::Char('q')));
|
||||
app.chat_widget
|
||||
.apply_keymap_update(codex_config::types::TuiKeymap::default(), &app.keymap);
|
||||
while operations.try_recv().is_ok() {}
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let (finish_tx, finish_rx) = tokio::sync::oneshot::channel();
|
||||
let late_input_at = std::cell::Cell::new(None);
|
||||
let input = [
|
||||
TuiEvent::Key(KeyCode::Enter.into()),
|
||||
TuiEvent::Key(KeyCode::Char('q').into()),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
||||
TuiEvent::Key(KeyCode::Char('b').into()),
|
||||
TuiEvent::Paste(" more".into()),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)),
|
||||
TuiEvent::Key(KeyCode::Char('a').into()),
|
||||
TuiEvent::Key(KeyCode::Char('a').into()),
|
||||
];
|
||||
let paste_keys = [
|
||||
KeyCode::Char('b'),
|
||||
KeyCode::Char('b'),
|
||||
KeyCode::Tab,
|
||||
KeyCode::Char('c'),
|
||||
KeyCode::Char('c'),
|
||||
KeyCode::Char('?'),
|
||||
];
|
||||
let events = tokio_stream::iter(input)
|
||||
.chain(futures::stream::once(async move {
|
||||
finish_tx.send(()).expect("finish Cloud switch");
|
||||
TuiEvent::Draw
|
||||
}))
|
||||
.chain(futures::stream::once(async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(70)).await;
|
||||
TuiEvent::Draw
|
||||
}))
|
||||
.chain(futures::stream::once(async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
late_input_at.set(Some(std::time::Instant::now()));
|
||||
TuiEvent::Key(KeyCode::Enter.into())
|
||||
}))
|
||||
.chain(tokio_stream::iter(
|
||||
paste_keys.map(|key| TuiEvent::Key(key.into())),
|
||||
));
|
||||
let result = wait_for_cloud_switch(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
async {
|
||||
finish_rx.await.expect("release Cloud switch");
|
||||
Err::<(), _>("Cloud unavailable")
|
||||
},
|
||||
Box::pin(events),
|
||||
ClipboardHandoff::SettleBeforeNextView,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, Err("Cloud unavailable"));
|
||||
let rendered = buffer_text(tui.terminal.previous_buffer());
|
||||
insta::assert_snapshot!(
|
||||
rendered
|
||||
.lines()
|
||||
.find(|line| line.contains("Cloud conversation did not open"))
|
||||
.unwrap_or_default()
|
||||
.trim(),
|
||||
@"Cloud conversation did not open draft kept"
|
||||
);
|
||||
let draft = app.chat_widget.composer_draft_snapshot();
|
||||
assert_eq!(draft.text, "/review first draft more\naa\nbb\tcc?");
|
||||
assert!(
|
||||
draft.last_composer_activity_at
|
||||
>= Some(late_input_at.get().expect("late input was delivered"))
|
||||
);
|
||||
assert!(operations.try_recv().is_err());
|
||||
assert!(
|
||||
!std::iter::from_fn(|| app_events.try_recv().ok()).any(|event| matches!(
|
||||
event,
|
||||
AppEvent::CodexOp(AppCommand::UserTurn { .. }) | AppEvent::UpdateReasoningEffort(_)
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cloud_switch_wait_routes_ctrl_d_to_the_draft_or_queues_shutdown() -> Result<()> {
|
||||
use crate::app::cloud_switch_wait::ClipboardHandoff;
|
||||
use crate::app::cloud_switch_wait::wait_for_cloud_switch;
|
||||
use crate::app_event::ExitMode;
|
||||
|
||||
let (mut app, mut app_events, _operations) = make_test_app_with_channels().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let ctrl_d = TuiEvent::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
let input = [
|
||||
TuiEvent::Key(KeyCode::Char('a').into()),
|
||||
ctrl_d.clone(), // Buffered typing counts as draft content.
|
||||
TuiEvent::Key(KeyCode::Home.into()),
|
||||
ctrl_d.clone(), // Forward-delete the draft's only character.
|
||||
ctrl_d, // The draft is now empty.
|
||||
];
|
||||
{
|
||||
let wait = wait_for_cloud_switch(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
std::future::pending::<Result<(), std::convert::Infallible>>(),
|
||||
tokio_stream::iter(input).chain(tokio_stream::pending()),
|
||||
ClipboardHandoff::SettleBeforeNextView,
|
||||
);
|
||||
tokio::pin!(wait);
|
||||
assert!(futures::poll!(&mut wait).is_pending());
|
||||
}
|
||||
assert_eq!(app.chat_widget.composer_draft_snapshot().text, "");
|
||||
let exits: Vec<_> = std::iter::from_fn(|| app_events.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::Exit(mode) => Some(mode),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exits, [ExitMode::ShutdownFirst]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cloud_switch_wait_preserves_legacy_vim_escape_and_search_acceptance() -> Result<()> {
|
||||
use crate::app::cloud_switch_wait::ClipboardHandoff;
|
||||
use crate::app::cloud_switch_wait::wait_for_cloud_switch;
|
||||
|
||||
let (mut app, _app_events, _operations) = make_test_app_with_channels().await;
|
||||
app.enhanced_keys_supported = false;
|
||||
app.chat_widget.toggle_vim_mode_and_notify();
|
||||
app.chat_widget.insert_str("a");
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
wait_for_cloud_switch(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
std::future::ready(Ok::<(), std::convert::Infallible>(())),
|
||||
tokio_stream::iter([
|
||||
TuiEvent::Key(KeyCode::Char('i').into()),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
|
||||
TuiEvent::Paste("alpha beta".into()),
|
||||
TuiEvent::Key(KeyCode::Char('?').into()),
|
||||
TuiEvent::Key(KeyCode::Char('l').into()),
|
||||
TuiEvent::Key(KeyCode::Enter.into()),
|
||||
TuiEvent::Key(KeyCode::Char('x').into()),
|
||||
]),
|
||||
ClipboardHandoff::SettleBeforeNextView,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(app.chat_widget.composer_draft_snapshot().text, "apha beta");
|
||||
let draft_mode = crate::bottom_pane::RestrictedInputMode::DraftHandoff;
|
||||
for (key, expected) in [
|
||||
(KeyCode::Char('u').into(), "alpha beta"),
|
||||
(
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
"apha beta",
|
||||
),
|
||||
] {
|
||||
app.chat_widget.handle_restricted_key(key, draft_mode);
|
||||
assert_eq!(app.chat_widget.composer_draft_snapshot().text, expected);
|
||||
}
|
||||
let draft = app.chat_widget.composer_draft_snapshot();
|
||||
assert!(draft.vim_normal_mode);
|
||||
// Replace the composer as a successful Cloud switch does. Startup adoption alone
|
||||
// would return this draft to Insert, so the next x would insert instead of delete.
|
||||
let (mut destination, _app_events, _operations) = make_test_app_with_channels().await;
|
||||
destination.chat_widget.local_settings.tui.vim_mode_default = true;
|
||||
destination.chat_widget.toggle_vim_mode_and_notify();
|
||||
destination.chat_widget.restore_cloud_switch_draft(draft);
|
||||
destination
|
||||
.chat_widget
|
||||
.handle_restricted_key(KeyCode::Char('x').into(), draft_mode);
|
||||
insta::assert_snapshot!(destination.chat_widget.composer_draft_snapshot().text, @"aha beta");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_switch_wait_displays_pending_submission_hint() -> Result<()> {
|
||||
use crate::app::cloud_switch_wait::ClipboardHandoff;
|
||||
use crate::app::cloud_switch_wait::wait_for_cloud_switch;
|
||||
|
||||
let (mut app, _app_events, _operations) = make_test_app_with_channels().await;
|
||||
let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf());
|
||||
app.chat_widget.handle_thread_session(session);
|
||||
app.chat_widget
|
||||
.set_new_backend_control(Some("Cloud".into()));
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
{
|
||||
let wait = wait_for_cloud_switch(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
std::future::pending::<Result<(), std::convert::Infallible>>(),
|
||||
tokio_stream::iter([
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
|
||||
TuiEvent::Draw,
|
||||
])
|
||||
.chain(tokio_stream::pending()),
|
||||
ClipboardHandoff::SettleBeforeNextView,
|
||||
);
|
||||
tokio::pin!(wait);
|
||||
assert!(futures::poll!(&mut wait).is_pending());
|
||||
}
|
||||
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||||
insta::assert_snapshot!(
|
||||
rendered
|
||||
.lines()
|
||||
.find(|line| line.contains("Connecting to Cloud"))
|
||||
.expect("Cloud connection hint")
|
||||
.trim(),
|
||||
@"Connecting to Cloud… submit when ready"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@ async fn queued_start_rejection_restores_the_draft() -> Result<()> {
|
||||
(
|
||||
ThreadParamsMode::Cloud,
|
||||
-32602,
|
||||
"Cloud conversations currently accept plain text only.",
|
||||
"Prepare local images before sending them to Cloud.",
|
||||
),
|
||||
] {
|
||||
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
|
||||
|
||||
@@ -2160,3 +2160,22 @@ async fn check_provider_resume_retries(reopen_via: ProviderResumeRoute) -> Resul
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_switch_wait_applies_navigation_before_polling_terminal_input() -> Result<()> {
|
||||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||||
app.config.features.enable(Feature::TuiMultiBackend)?;
|
||||
let tui = crate::tui::test_support::make_test_tui()?;
|
||||
while events.try_recv().is_ok() {}
|
||||
|
||||
for navigation in [
|
||||
AppEvent::CycleNewConversationBackend,
|
||||
AppEvent::ChooseNewSessionCheckout { name: None },
|
||||
] {
|
||||
assert!(app.can_read_terminal_after_thread_switch(&tui, &events));
|
||||
app.app_event_tx.send(navigation);
|
||||
assert!(!app.can_read_terminal_after_thread_switch(&tui, &events));
|
||||
assert!(events.try_recv().is_ok());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user