mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +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,6 +2,7 @@
|
||||
|
||||
use codex_app_server_protocol as protocol;
|
||||
use codex_cloud_client as cloud;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::bail;
|
||||
|
||||
@@ -92,15 +93,49 @@ pub(super) fn item_summary(item: cloud::ItemSummary) -> Option<protocol::ThreadI
|
||||
cloud::ItemSummary::UserMessage {
|
||||
id,
|
||||
client_id,
|
||||
text,
|
||||
content,
|
||||
} => protocol::ThreadItemPayload::UserMessage {
|
||||
id,
|
||||
client_id,
|
||||
content: text
|
||||
content: content
|
||||
.into_iter()
|
||||
.map(|text| protocol::UserInput::Text {
|
||||
text,
|
||||
text_elements: Vec::new(),
|
||||
.map(|item| match item {
|
||||
cloud::UserMessageInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
} => protocol::UserInput::Text {
|
||||
text,
|
||||
text_elements: text_elements
|
||||
.into_iter()
|
||||
.map(|element| {
|
||||
protocol::TextElement::new(
|
||||
protocol::ByteRange {
|
||||
start: element.start,
|
||||
end: element.end,
|
||||
},
|
||||
element.placeholder,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
cloud::UserMessageInput::Image { image, detail } => {
|
||||
protocol::UserInput::Image {
|
||||
image: match image {
|
||||
cloud::ImageReference::Url(url) => {
|
||||
protocol::ImageReference::Inline { url }
|
||||
}
|
||||
cloud::ImageReference::FileId(file_id) => {
|
||||
protocol::ImageReference::File { file_id }
|
||||
}
|
||||
},
|
||||
detail: detail.map(|detail| match detail {
|
||||
cloud::ImageDetail::Auto => ImageDetail::Auto,
|
||||
cloud::ImageDetail::Low => ImageDetail::Low,
|
||||
cloud::ImageDetail::High => ImageDetail::High,
|
||||
cloud::ImageDetail::Original => ImageDetail::Original,
|
||||
}),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
|
||||
@@ -4,11 +4,25 @@ use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn text_receipt_preserves_submission_identity() {
|
||||
fn image_receipt_preserves_submission_identity_and_attachment() {
|
||||
let label = "[Image #1]";
|
||||
let actual = item_summary(cloud::ItemSummary::UserMessage {
|
||||
id: "item-1".into(),
|
||||
client_id: Some("submission-1".into()),
|
||||
text: vec!["hello".into(), "world".into()],
|
||||
content: vec![
|
||||
cloud::UserMessageInput::Image {
|
||||
image: cloud::ImageReference::FileId("file-synthetic".into()),
|
||||
detail: Some(cloud::ImageDetail::High),
|
||||
},
|
||||
cloud::UserMessageInput::Text {
|
||||
text: format!("{label} describe"),
|
||||
text_elements: vec![cloud::UserMessageTextElement {
|
||||
start: 0,
|
||||
end: label.len(),
|
||||
placeholder: Some(label.into()),
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert_eq!(
|
||||
actual,
|
||||
@@ -17,13 +31,21 @@ fn text_receipt_preserves_submission_identity() {
|
||||
id: "item-1".into(),
|
||||
client_id: Some("submission-1".into()),
|
||||
content: vec![
|
||||
protocol::UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new()
|
||||
protocol::UserInput::Image {
|
||||
image: protocol::ImageReference::File {
|
||||
file_id: "file-synthetic".into(),
|
||||
},
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
protocol::UserInput::Text {
|
||||
text: "world".into(),
|
||||
text_elements: Vec::new()
|
||||
text: format!("{label} describe"),
|
||||
text_elements: vec![protocol::TextElement::new(
|
||||
protocol::ByteRange {
|
||||
start: 0,
|
||||
end: label.len(),
|
||||
},
|
||||
Some(label.into()),
|
||||
)],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -225,12 +225,7 @@ async fn run_worker(
|
||||
{
|
||||
-32601
|
||||
} else if error.is::<PreflightRejection>()
|
||||
|| user_message_submission
|
||||
&& (error.is::<MissingReplyAttachment>()
|
||||
|| matches!(
|
||||
error.downcast_ref::<cloud::MutationError>(),
|
||||
Some(cloud::MutationError::InvalidRequest)
|
||||
))
|
||||
|| user_message_submission && error.is::<MissingReplyAttachment>()
|
||||
{
|
||||
-32602
|
||||
} else {
|
||||
|
||||
@@ -63,7 +63,13 @@ fn user_input_uses_stable_id_and_the_existing_cloud_configuration() {
|
||||
let request = cloud_message(message()).unwrap();
|
||||
assert_eq!(request.thread_id, "thread-a");
|
||||
assert_eq!(request.client_user_message_id, "submission-a");
|
||||
assert_eq!(request.text, "one\ntwo");
|
||||
assert_eq!(
|
||||
request.input,
|
||||
vec![cloud::UserMessageInput::Text {
|
||||
text: "one\ntwo".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
request.turn_trigger.as_deref(),
|
||||
Some("safety_recourse_follow_up")
|
||||
@@ -89,6 +95,51 @@ fn user_input_uses_stable_id_and_the_existing_cloud_configuration() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_submission_keeps_attachment_markers_and_input_order() {
|
||||
let label = "[Image #1]";
|
||||
let request = cloud_message(protocol::TurnStartParams {
|
||||
input: vec![
|
||||
protocol::UserInput::Image {
|
||||
image: protocol::ImageReference::File {
|
||||
file_id: "file-synthetic".into(),
|
||||
},
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
protocol::UserInput::Text {
|
||||
text: format!("{label} describe"),
|
||||
text_elements: vec![protocol::TextElement::new(
|
||||
protocol::ByteRange {
|
||||
start: 0,
|
||||
end: label.len(),
|
||||
},
|
||||
Some(label.into()),
|
||||
)],
|
||||
},
|
||||
],
|
||||
..message()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
request.input,
|
||||
vec![
|
||||
cloud::UserMessageInput::Image {
|
||||
image: cloud::ImageReference::FileId("file-synthetic".into()),
|
||||
detail: Some(cloud::ImageDetail::High),
|
||||
},
|
||||
cloud::UserMessageInput::Text {
|
||||
text: format!("{label} describe"),
|
||||
text_elements: vec![cloud::UserMessageTextElement {
|
||||
start: 0,
|
||||
end: label.len(),
|
||||
placeholder: Some(label.into()),
|
||||
}],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_queued_admissions_never_invent_a_running_turn() {
|
||||
for outcome in [
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::send_notification;
|
||||
use crate::CloudUserMessageReceipt;
|
||||
use codex_app_server_protocol as protocol;
|
||||
use codex_cloud_client as cloud;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::bail;
|
||||
use color_eyre::eyre::eyre;
|
||||
@@ -194,7 +195,15 @@ impl Requests {
|
||||
Request::TurnStart { params, .. } => {
|
||||
let mut message = cloud_message(params)?;
|
||||
message.mode = mode;
|
||||
let admission = self.client.add_user_message(&message).await?;
|
||||
let admission = match self.client.add_user_message(&message).await {
|
||||
Err(cloud::MutationError::InvalidRequest) => {
|
||||
return Err(PreflightRejection(
|
||||
"The Cloud message is empty, invalid, or too large.",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
if matches!(mode, cloud::SubmissionMode::Steer) {
|
||||
serde_json::to_value(admitted_message(admission.outcome)?)?
|
||||
} else {
|
||||
@@ -299,23 +308,71 @@ fn cloud_message(
|
||||
"Changing cloud settings or sending structured input is not supported yet.",
|
||||
));
|
||||
}
|
||||
let text = input
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
let mut content = Vec::with_capacity(input.len());
|
||||
for item in input {
|
||||
let item = match item {
|
||||
protocol::UserInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
} if text_elements.is_empty() => Ok(text),
|
||||
_ => Err(PreflightRejection(
|
||||
"Cloud conversations currently accept plain text only.",
|
||||
)),
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join("\n");
|
||||
} => {
|
||||
// Keep the existing line separation for consecutive plain text parts.
|
||||
if text_elements.is_empty()
|
||||
&& let Some(cloud::UserMessageInput::Text {
|
||||
text: previous,
|
||||
text_elements: previous_elements,
|
||||
}) = content.last_mut()
|
||||
&& previous_elements.is_empty()
|
||||
{
|
||||
previous.push('\n');
|
||||
previous.push_str(&text);
|
||||
continue;
|
||||
}
|
||||
cloud::UserMessageInput::Text {
|
||||
text,
|
||||
text_elements: text_elements
|
||||
.into_iter()
|
||||
.map(|element| cloud::UserMessageTextElement {
|
||||
placeholder: element.placeholder().map(str::to_owned),
|
||||
start: element.byte_range.start,
|
||||
end: element.byte_range.end,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
protocol::UserInput::Image { image, detail } => cloud::UserMessageInput::Image {
|
||||
image: match image {
|
||||
protocol::ImageReference::Inline { url } => cloud::ImageReference::Url(url),
|
||||
protocol::ImageReference::File { file_id } => {
|
||||
cloud::ImageReference::FileId(file_id)
|
||||
}
|
||||
},
|
||||
detail: detail.map(|detail| match detail {
|
||||
ImageDetail::Auto => cloud::ImageDetail::Auto,
|
||||
ImageDetail::Low => cloud::ImageDetail::Low,
|
||||
ImageDetail::High => cloud::ImageDetail::High,
|
||||
ImageDetail::Original => cloud::ImageDetail::Original,
|
||||
}),
|
||||
},
|
||||
protocol::UserInput::LocalImage { .. } => {
|
||||
return Err(PreflightRejection(
|
||||
"Prepare local images before sending them to Cloud.",
|
||||
));
|
||||
}
|
||||
protocol::UserInput::Audio { .. }
|
||||
| protocol::UserInput::LocalAudio { .. }
|
||||
| protocol::UserInput::Skill { .. }
|
||||
| protocol::UserInput::Mention { .. } => {
|
||||
return Err(PreflightRejection(
|
||||
"Cloud conversations currently accept text and images.",
|
||||
));
|
||||
}
|
||||
};
|
||||
content.push(item);
|
||||
}
|
||||
Ok(cloud::AddUserMessageRequest {
|
||||
thread_id,
|
||||
client_user_message_id,
|
||||
text,
|
||||
input: content,
|
||||
turn_trigger,
|
||||
mode: cloud::SubmissionMode::StartIfIdle,
|
||||
shared_access: false,
|
||||
|
||||
@@ -74,8 +74,17 @@ async fn native_submission_uses_one_admission_and_keeps_preflight_guards() -> Re
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let Err(crate::TypedRequestError::Server { source, .. }) =
|
||||
cloud_handle.submit_user_message(submission("x")).await
|
||||
let url = "data:image/png;base64,iVBORw0KGgo=";
|
||||
let image = protocol::UserInput::Image {
|
||||
image: protocol::ImageReference::Inline { url: url.into() },
|
||||
detail: None,
|
||||
};
|
||||
let Err(crate::TypedRequestError::Server { source, .. }) = cloud_handle
|
||||
.submit_user_message(protocol::TurnStartParams {
|
||||
input: vec![image.clone()],
|
||||
..submission("x")
|
||||
})
|
||||
.await
|
||||
else {
|
||||
panic!("a lost receipt must remain uncertain");
|
||||
};
|
||||
@@ -85,7 +94,16 @@ async fn native_submission_uses_one_admission_and_keeps_preflight_guards() -> Re
|
||||
let Err(crate::TypedRequestError::Server { source, .. }) = handle
|
||||
.request_typed::<protocol::TurnStartResponse>(protocol::ClientRequest::TurnStart {
|
||||
request_id: protocol::RequestId::Integer(1),
|
||||
params: submission("m"),
|
||||
params: protocol::TurnStartParams {
|
||||
input: vec![
|
||||
image,
|
||||
protocol::UserInput::Text {
|
||||
text: "describe the image".into(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
],
|
||||
..submission("m")
|
||||
},
|
||||
})
|
||||
.await
|
||||
else {
|
||||
@@ -135,8 +153,22 @@ async fn native_submission_uses_one_admission_and_keeps_preflight_guards() -> Re
|
||||
assert_eq!(queued.code, -32602);
|
||||
assert_eq!(
|
||||
queued.message,
|
||||
"Cloud conversations currently accept plain text only."
|
||||
"Prepare local images before sending them to Cloud."
|
||||
);
|
||||
let invalid_image = handle
|
||||
.request(protocol::ClientRequest::TurnStart {
|
||||
request_id: protocol::RequestId::Integer(4),
|
||||
params: protocol::TurnStartParams {
|
||||
input: vec![protocol::UserInput::Image {
|
||||
image: protocol::ImageReference::Inline { url: String::new() },
|
||||
detail: None,
|
||||
}],
|
||||
..submission("invalid-image")
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect_err("an invalid image must fail before admission");
|
||||
assert_eq!(invalid_image.code, -32602);
|
||||
|
||||
drop(reply_sender);
|
||||
let Err(crate::TypedRequestError::Server { source, .. }) =
|
||||
@@ -157,6 +189,12 @@ async fn native_submission_uses_one_admission_and_keeps_preflight_guards() -> Re
|
||||
client.shutdown().await?;
|
||||
worker.await?;
|
||||
let calls = server.received_requests().await.unwrap();
|
||||
assert!(calls.iter().all(|request| {
|
||||
request
|
||||
.body
|
||||
.windows(url.len())
|
||||
.any(|bytes| bytes == url.as_bytes())
|
||||
}));
|
||||
assert_eq!(
|
||||
calls
|
||||
.iter()
|
||||
|
||||
@@ -60,7 +60,19 @@ notifications and uncertain overlap require authoritative history reload. This
|
||||
crate exposes the read primitives; a coordinated resync/display state machine is
|
||||
still required before claiming lossless resume or live/history reconciliation.
|
||||
|
||||
`add_user_message` submits plain text using the thread's existing configuration.
|
||||
`add_user_message` submits ordered text and image parts using the thread's existing
|
||||
configuration. Use `UserMessageInput::Text` or `UserMessageInput::Image` with an
|
||||
`ImageReference::Url` (inline data URLs only) or `ImageReference::FileId` and optional
|
||||
`ImageDetail`. Image-only input is supported; local paths must be converted by the
|
||||
caller before submission. URL prefixes are case-insensitive and must contain
|
||||
nonempty data; HTTP image URLs are rejected locally because the backend would
|
||||
omit them. File IDs must be nonblank. Requests containing images have a 3.75 MiB
|
||||
encoded-envelope cap, reserving 256 KiB below CCA's 4 MiB forwarding limit. Plain
|
||||
text retains the 64 MiB encoded-message limit. Text parts carry optional UTF-8
|
||||
byte spans through `UserMessageTextElement`; empty span lists preserve the plain
|
||||
text wire encoding. User-message summaries retain ordered content and client IDs
|
||||
for both text and image echoes.
|
||||
|
||||
Choose `StartIfIdle`, `Steer`, or `Queue` explicitly; admission distinguishes
|
||||
started, steered, duplicate and queued results. Persist a unique client message ID
|
||||
with each logical input and keep it unchanged when reconciling an uncertain
|
||||
@@ -75,8 +87,8 @@ sending. `OutcomeUnknown` retains the source error: even a server error can foll
|
||||
admission, so status codes alone do not prove rejection. Malformed success replies
|
||||
and mismatched response IDs are also uncertain. No automatic retry is performed;
|
||||
dropping a pending call does not undo remote work. Reconcile with server state
|
||||
before deciding whether another attempt is safe. Full input/configuration overrides
|
||||
and automated outcome reconciliation are not implemented by this text-only slice.
|
||||
before deciding whether another attempt is safe. Other input kinds, configuration
|
||||
overrides and automated outcome reconciliation are not implemented.
|
||||
|
||||
The attach example accepts two optional additional arguments to explicitly send
|
||||
one message after attaching: a persisted client message ID and the text. It uses
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_cloud_client::Client;
|
||||
use codex_cloud_client::Credentials;
|
||||
use codex_cloud_client::ResumeRequest;
|
||||
use codex_cloud_client::SubmissionMode;
|
||||
use codex_cloud_client::UserMessageInput;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use futures::StreamExt;
|
||||
@@ -24,7 +25,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
(Some(client_user_message_id), Some(text)) => Some(AddUserMessageRequest {
|
||||
thread_id: thread_id.clone(),
|
||||
client_user_message_id,
|
||||
text,
|
||||
input: vec![UserMessageInput::Text {
|
||||
text: text,
|
||||
text_elements: vec![],
|
||||
}],
|
||||
turn_trigger: None,
|
||||
mode: SubmissionMode::StartIfIdle,
|
||||
shared_access: false,
|
||||
|
||||
@@ -88,7 +88,75 @@ fn user_message_preserves_client_id_and_text() {
|
||||
Some(ItemSummary::UserMessage {
|
||||
id: "user-item".into(),
|
||||
client_id: Some("client-id".into()),
|
||||
text: vec!["hello".into()],
|
||||
content: vec![UserMessageInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: vec![]
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_preserves_images_spans_and_client_id() {
|
||||
let url = "data:image/png;base64,AA==";
|
||||
let label = "[Image #1]";
|
||||
let element = [
|
||||
message(/*tag*/ 1, b"\x08\x0d\x10\x03"),
|
||||
message(/*tag*/ 2, label.as_bytes()),
|
||||
]
|
||||
.concat();
|
||||
let text = [
|
||||
message(/*tag*/ 1, "é [Image #1]".as_bytes()),
|
||||
message(/*tag*/ 2, &message(/*tag*/ 1, &element)),
|
||||
]
|
||||
.concat();
|
||||
let image_bytes = [vec![0x08, 99], message(/*tag*/ 2, url.as_bytes())].concat();
|
||||
let content = [
|
||||
message(/*tag*/ 1, &message(/*tag*/ 1, &text)),
|
||||
message(
|
||||
/*tag*/ 1,
|
||||
&message(/*tag*/ 2, &message(/*tag*/ 1, &image_bytes)),
|
||||
),
|
||||
]
|
||||
.concat();
|
||||
let user = message(
|
||||
/*tag*/ 1,
|
||||
&[
|
||||
message(/*tag*/ 1, b"client-id"),
|
||||
message(/*tag*/ 2, &content),
|
||||
message(/*tag*/ 3, b"user-item"),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
let expected = ItemSummary::UserMessage {
|
||||
id: "user-item".into(),
|
||||
client_id: Some("client-id".into()),
|
||||
content: vec![
|
||||
UserMessageInput::Text {
|
||||
text: "é [Image #1]".into(),
|
||||
text_elements: vec![UserMessageTextElement {
|
||||
start: 3,
|
||||
end: 13,
|
||||
placeholder: Some(label.into()),
|
||||
}],
|
||||
},
|
||||
UserMessageInput::Image {
|
||||
image: ImageReference::Url(url.into()),
|
||||
detail: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(summary(&user), Some(expected.clone()));
|
||||
assert_eq!(format!("{expected:?}"), "ItemSummary([redacted])");
|
||||
let event = TranscriptEvent::decode("t", &lifecycle(/*tag*/ 31, &user))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
event.update,
|
||||
TranscriptUpdate::ItemCompleted(ItemLifecycle {
|
||||
turn_id: "u".into(),
|
||||
at_ms: 5,
|
||||
item: expected,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -261,6 +329,46 @@ fn unsupported_and_incomplete_items_stay_opaque() {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
for input in [
|
||||
message(/*tag*/ 8, b""), // Future input variant.
|
||||
message(/*tag*/ 2, &message(/*tag*/ 3, b"")), // Future image reference.
|
||||
message(/*tag*/ 2, &message(/*tag*/ 1, b"\x08\x01")), // Missing URL.
|
||||
message(/*tag*/ 2, &message(/*tag*/ 2, b"")), // Missing file ID.
|
||||
message(
|
||||
/*tag*/ 1,
|
||||
&[
|
||||
message(/*tag*/ 1, b"hello"),
|
||||
message(/*tag*/ 2, &message(/*tag*/ 1, b"")), // Missing byte range.
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
message(
|
||||
/*tag*/ 1,
|
||||
&[
|
||||
message(/*tag*/ 1, b"hello"),
|
||||
message(
|
||||
/*tag*/ 2,
|
||||
&message(/*tag*/ 1, &message(/*tag*/ 1, b"\x08\x01")),
|
||||
), // Missing start.
|
||||
]
|
||||
.concat(),
|
||||
),
|
||||
] {
|
||||
let user = message(
|
||||
/*tag*/ 1,
|
||||
&[
|
||||
message(/*tag*/ 1, b"client-id"),
|
||||
message(/*tag*/ 2, &message(/*tag*/ 1, &input)),
|
||||
message(/*tag*/ 3, b"user-item"),
|
||||
]
|
||||
.concat(),
|
||||
);
|
||||
assert!(
|
||||
TranscriptEvent::decode("t", &lifecycle(/*tag*/ 27, &user))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
TranscriptEvent::decode("t", ¬ification(/*tag*/ 27, &absent_time))
|
||||
.unwrap()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Selected item summaries for live start/completion events, without interpreting executor paths.
|
||||
use crate::FileUpdate;
|
||||
use crate::ImageDetail;
|
||||
use crate::ImageReference;
|
||||
use crate::TranscriptUpdate;
|
||||
use crate::UserMessageInput;
|
||||
use crate::UserMessageTextElement;
|
||||
use crate::item_wire as wire;
|
||||
use crate::transcript::DecodeFailure;
|
||||
use crate::transcript::ITEM_STARTED;
|
||||
@@ -24,7 +28,7 @@ pub enum ItemSummary {
|
||||
UserMessage {
|
||||
id: String,
|
||||
client_id: Option<String>,
|
||||
text: Vec<String>,
|
||||
content: Vec<UserMessageInput>,
|
||||
},
|
||||
AgentMessage {
|
||||
id: String,
|
||||
@@ -169,17 +173,62 @@ pub(crate) fn decode(
|
||||
pub(crate) fn summary(item: wire::Item) -> Result<Option<ItemSummary>, DecodeFailure> {
|
||||
let summary = match item.value {
|
||||
Some(wire::Value::UserMessage(value)) => {
|
||||
let mut text = Vec::new();
|
||||
let mut content = Vec::new();
|
||||
for input in required(value.content)?.values {
|
||||
let Some(crate::turns_wire::Value::Text(input)) = input.value else {
|
||||
return Ok(None);
|
||||
let input = match input.value {
|
||||
Some(crate::turns_wire::Value::Text(input)) => {
|
||||
let text = required(input.text)?;
|
||||
let text_elements = input
|
||||
.text_elements
|
||||
.map_or_else(Vec::new, |elements| elements.values)
|
||||
.into_iter()
|
||||
.map(|element| {
|
||||
let range = required(element.byte_range)?;
|
||||
let start = usize::try_from(required(range.start)?)
|
||||
.map_err(|_| DecodeFailure::MissingField)?;
|
||||
let end = usize::try_from(required(range.end)?)
|
||||
.map_err(|_| DecodeFailure::MissingField)?;
|
||||
text.get(start..end).ok_or(DecodeFailure::MissingField)?;
|
||||
Ok(UserMessageTextElement {
|
||||
start,
|
||||
end,
|
||||
placeholder: element.placeholder,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, DecodeFailure>>()?;
|
||||
UserMessageInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
}
|
||||
}
|
||||
Some(crate::turns_wire::Value::Image(input)) => {
|
||||
let (image, detail) = match input.value {
|
||||
Some(crate::turns_wire::ImageValue::Url(image)) => {
|
||||
(ImageReference::Url(required(image.value)?), image.detail)
|
||||
}
|
||||
Some(crate::turns_wire::ImageValue::FileId(image)) => {
|
||||
(ImageReference::FileId(required(image.value)?), image.detail)
|
||||
}
|
||||
None => return Ok(None),
|
||||
};
|
||||
let detail = match detail {
|
||||
Some(1) => Some(ImageDetail::Auto),
|
||||
Some(2) => Some(ImageDetail::Low),
|
||||
Some(3) => Some(ImageDetail::High),
|
||||
Some(4) => Some(ImageDetail::Original),
|
||||
// Detail is display metadata. Keep the receipt identity if it is newer.
|
||||
None | Some(_) => None,
|
||||
};
|
||||
UserMessageInput::Image { image, detail }
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
text.push(required(input.text)?);
|
||||
content.push(input);
|
||||
}
|
||||
ItemSummary::UserMessage {
|
||||
id: required(value.id)?,
|
||||
client_id: value.client_id,
|
||||
text,
|
||||
content,
|
||||
}
|
||||
}
|
||||
Some(wire::Value::AgentMessage(value)) => ItemSummary::AgentMessage {
|
||||
|
||||
@@ -69,9 +69,13 @@ pub use transcript::TurnSummary;
|
||||
pub use turns::AddUserMessageRequest;
|
||||
pub use turns::Admission;
|
||||
pub use turns::AdmissionOutcome;
|
||||
pub use turns::ImageDetail;
|
||||
pub use turns::ImageReference;
|
||||
pub use turns::InterruptTurnRequest;
|
||||
pub use turns::MutationError;
|
||||
pub use turns::SubmissionMode;
|
||||
pub use turns::UserMessageInput;
|
||||
pub use turns::UserMessageTextElement;
|
||||
pub use types::Credentials;
|
||||
pub use types::Error;
|
||||
pub use types::Event;
|
||||
|
||||
@@ -9,24 +9,59 @@ use bytes::Bytes;
|
||||
use prost::Message;
|
||||
use std::fmt;
|
||||
|
||||
// CCA forwards the envelope through a 4 MiB boundary; reserve 256 KiB for forwarding.
|
||||
const MAX_IMAGE_MESSAGE_BYTES: usize = 4 * 1024 * 1024 - 256 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum SubmissionMode {
|
||||
Steer,
|
||||
Queue,
|
||||
StartIfIdle,
|
||||
}
|
||||
/// Plain text using the existing thread configuration. Persist the message ID with
|
||||
/// Text and images using the existing thread configuration. Persist the message ID with
|
||||
/// the input before calling; never reuse it for a different logical submission.
|
||||
#[derive(Clone)]
|
||||
pub struct AddUserMessageRequest {
|
||||
pub thread_id: String,
|
||||
pub client_user_message_id: String,
|
||||
pub text: String,
|
||||
pub input: Vec<UserMessageInput>,
|
||||
/// Optional classification supplied by the caller for this turn.
|
||||
pub turn_trigger: Option<String>,
|
||||
pub mode: SubmissionMode,
|
||||
pub shared_access: bool,
|
||||
}
|
||||
/// Ordered input parts for one logical user message.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum UserMessageInput {
|
||||
Text {
|
||||
text: String,
|
||||
text_elements: Vec<UserMessageTextElement>,
|
||||
},
|
||||
Image {
|
||||
image: ImageReference,
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
}
|
||||
/// A placeholder span in the UTF-8 bytes of a text input part.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct UserMessageTextElement {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub placeholder: Option<String>,
|
||||
}
|
||||
/// An inline data URL or an uploaded file ID. HTTP image URLs are not supported.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ImageReference {
|
||||
Url(String),
|
||||
FileId(String),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ImageDetail {
|
||||
Auto,
|
||||
Low,
|
||||
High,
|
||||
Original,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct InterruptTurnRequest {
|
||||
pub thread_id: String,
|
||||
@@ -73,6 +108,9 @@ macro_rules! redacted_debug {
|
||||
}
|
||||
redacted_debug!(
|
||||
AddUserMessageRequest,
|
||||
UserMessageInput,
|
||||
UserMessageTextElement,
|
||||
ImageReference,
|
||||
InterruptTurnRequest,
|
||||
Admission,
|
||||
AdmissionOutcome
|
||||
@@ -87,18 +125,86 @@ impl Client {
|
||||
) -> Result<Admission, MutationError> {
|
||||
if request.thread_id.trim().is_empty()
|
||||
|| request.client_user_message_id.trim().is_empty()
|
||||
|| request.text.trim().is_empty()
|
||||
|| !request.input.iter().any(|input| match input {
|
||||
UserMessageInput::Text { text, .. } => !text.trim().is_empty(),
|
||||
UserMessageInput::Image { .. } => true,
|
||||
})
|
||||
|| request.input.iter().any(|input| match input {
|
||||
UserMessageInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
} => text_elements
|
||||
.iter()
|
||||
.any(|element| text.get(element.start..element.end).is_none()),
|
||||
UserMessageInput::Image { image, .. } => match image {
|
||||
ImageReference::Url(value) => {
|
||||
value.split_once(',').is_none_or(|(prefix, payload)| {
|
||||
!prefix
|
||||
.get(..5)
|
||||
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:"))
|
||||
|| payload.trim().is_empty()
|
||||
})
|
||||
}
|
||||
ImageReference::FileId(value) => value.trim().is_empty(),
|
||||
},
|
||||
})
|
||||
{
|
||||
return Err(MutationError::InvalidRequest);
|
||||
}
|
||||
let value = wire::AddRequest {
|
||||
client_user_message_id: request.client_user_message_id.clone(),
|
||||
input: Some(wire::Input {
|
||||
values: vec![wire::UserInput {
|
||||
value: Some(wire::Value::Text(wire::Text {
|
||||
text: Some(request.text.clone()),
|
||||
})),
|
||||
}],
|
||||
values: request
|
||||
.input
|
||||
.iter()
|
||||
.map(|input| wire::UserInput {
|
||||
value: Some(match input {
|
||||
UserMessageInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
} => wire::Value::Text(wire::Text {
|
||||
text: Some(text.clone()),
|
||||
text_elements: (!text_elements.is_empty()).then(|| {
|
||||
wire::TextElements {
|
||||
values: text_elements
|
||||
.iter()
|
||||
.map(|element| wire::TextElement {
|
||||
byte_range: Some(wire::ByteRange {
|
||||
end: Some(element.end as u64),
|
||||
start: Some(element.start as u64),
|
||||
}),
|
||||
placeholder: element.placeholder.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}),
|
||||
}),
|
||||
UserMessageInput::Image { image, detail } => {
|
||||
let detail = detail.map(|detail| match detail {
|
||||
ImageDetail::Auto => 1,
|
||||
ImageDetail::Low => 2,
|
||||
ImageDetail::High => 3,
|
||||
ImageDetail::Original => 4,
|
||||
});
|
||||
let value = match image {
|
||||
ImageReference::Url(url) => {
|
||||
wire::ImageValue::Url(wire::ImageReference {
|
||||
detail,
|
||||
value: Some(url.clone()),
|
||||
})
|
||||
}
|
||||
ImageReference::FileId(file_id) => {
|
||||
wire::ImageValue::FileId(wire::ImageReference {
|
||||
detail,
|
||||
value: Some(file_id.clone()),
|
||||
})
|
||||
}
|
||||
};
|
||||
wire::Value::Image(wire::Image { value: Some(value) })
|
||||
}
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
thread_id: request.thread_id.clone(),
|
||||
turn_trigger: request.turn_trigger.clone(),
|
||||
@@ -109,6 +215,14 @@ impl Client {
|
||||
SubmissionMode::StartIfIdle => 3,
|
||||
}),
|
||||
};
|
||||
if request
|
||||
.input
|
||||
.iter()
|
||||
.any(|input| matches!(input, UserMessageInput::Image { .. }))
|
||||
&& value.encoded_len() > MAX_IMAGE_MESSAGE_BYTES
|
||||
{
|
||||
return Err(MutationError::InvalidRequest);
|
||||
}
|
||||
let payload = self
|
||||
.mutate(
|
||||
value,
|
||||
|
||||
@@ -6,7 +6,10 @@ fn request() -> AddUserMessageRequest {
|
||||
AddUserMessageRequest {
|
||||
thread_id: "t".into(),
|
||||
client_user_message_id: "m".into(),
|
||||
text: "hello".into(),
|
||||
input: vec![UserMessageInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: vec![],
|
||||
}],
|
||||
turn_trigger: None,
|
||||
mode: SubmissionMode::StartIfIdle,
|
||||
shared_access: true,
|
||||
@@ -106,6 +109,80 @@ async fn submission_preserves_admission_variants_and_wire_identity() {
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn image_submission_preserves_order_references_and_optional_detail() {
|
||||
let url = "DaTa:image/png;base64,AA==";
|
||||
let label = "[Image #1]";
|
||||
let text = format!("é {label}");
|
||||
let input = vec![
|
||||
UserMessageInput::Image {
|
||||
image: ImageReference::Url(url.into()),
|
||||
detail: None,
|
||||
},
|
||||
UserMessageInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: vec![UserMessageTextElement {
|
||||
start: 3,
|
||||
end: text.len(),
|
||||
placeholder: Some(label.into()),
|
||||
}],
|
||||
},
|
||||
UserMessageInput::Image {
|
||||
image: ImageReference::FileId("file-private".into()),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
];
|
||||
for part in &input {
|
||||
assert_eq!(format!("{part:?}"), "UserMessageInput([redacted])");
|
||||
}
|
||||
let mut fixture = fixture(response(
|
||||
vec![frame(&admitted(/*outcome*/ 3))],
|
||||
tonic::Status::ok(""),
|
||||
))
|
||||
.await;
|
||||
let result = fixture
|
||||
.client
|
||||
.add_user_message(&AddUserMessageRequest { input, ..request() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.outcome,
|
||||
AdmissionOutcome::Duplicate {
|
||||
turn_id: Some("u".into())
|
||||
}
|
||||
);
|
||||
|
||||
let span = [
|
||||
message(/*tag*/ 1, b"\x08\x0d\x10\x03"),
|
||||
message(/*tag*/ 2, label.as_bytes()),
|
||||
]
|
||||
.concat();
|
||||
let text = [
|
||||
message(/*tag*/ 1, text.as_bytes()),
|
||||
message(/*tag*/ 2, &message(/*tag*/ 1, &span)),
|
||||
]
|
||||
.concat();
|
||||
let url = message(/*tag*/ 1, &message(/*tag*/ 2, url.as_bytes()));
|
||||
let file = message(
|
||||
/*tag*/ 2,
|
||||
&[vec![0x08, 3], message(/*tag*/ 2, b"file-private")].concat(),
|
||||
);
|
||||
let values = [
|
||||
message(/*tag*/ 1, &message(/*tag*/ 2, &url)),
|
||||
message(/*tag*/ 1, &message(/*tag*/ 1, &text)),
|
||||
message(/*tag*/ 1, &message(/*tag*/ 2, &file)),
|
||||
]
|
||||
.concat();
|
||||
let expected = [
|
||||
message(/*tag*/ 4, b"m"),
|
||||
message(/*tag*/ 11, &values),
|
||||
message(/*tag*/ 23, b"t"),
|
||||
vec![0xd0, 1, 1, 0xf0, 1, 3],
|
||||
]
|
||||
.concat();
|
||||
assert_request(&mut fixture, "AddUserMessage", &expected).await;
|
||||
assert!(fixture.requests.try_recv().is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn duplicate_can_omit_turn_but_malformed_admission_is_unknown() {
|
||||
let payloads = vec![
|
||||
vec![],
|
||||
@@ -203,7 +280,51 @@ async fn invalid_input_never_sends_and_diagnostics_redact() {
|
||||
let mut fixture = fixture(response(vec![frame(&[])], tonic::Status::ok(""))).await;
|
||||
for input in [
|
||||
AddUserMessageRequest {
|
||||
text: " ".into(),
|
||||
input: vec![],
|
||||
..request()
|
||||
},
|
||||
AddUserMessageRequest {
|
||||
input: vec![UserMessageInput::Text {
|
||||
text: "x".repeat(MAX_MESSAGE_BYTES),
|
||||
text_elements: vec![],
|
||||
}],
|
||||
..request()
|
||||
},
|
||||
AddUserMessageRequest {
|
||||
input: vec![UserMessageInput::Image {
|
||||
image: ImageReference::Url(format!(
|
||||
"data:,{}",
|
||||
"A".repeat(4 * 1024 * 1024 - 256 * 1024)
|
||||
)),
|
||||
detail: None,
|
||||
}],
|
||||
..request()
|
||||
},
|
||||
AddUserMessageRequest {
|
||||
input: vec![UserMessageInput::Image {
|
||||
image: ImageReference::Url("https://example.com/image.png".into()),
|
||||
detail: None,
|
||||
}],
|
||||
..request()
|
||||
},
|
||||
AddUserMessageRequest {
|
||||
input: vec![
|
||||
UserMessageInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: vec![],
|
||||
},
|
||||
UserMessageInput::Image {
|
||||
image: ImageReference::FileId("".into()),
|
||||
detail: Some(ImageDetail::Auto),
|
||||
},
|
||||
],
|
||||
..request()
|
||||
},
|
||||
AddUserMessageRequest {
|
||||
input: vec![UserMessageInput::Text {
|
||||
text: " ".into(),
|
||||
text_elements: vec![],
|
||||
}],
|
||||
..request()
|
||||
},
|
||||
AddUserMessageRequest {
|
||||
@@ -221,6 +342,19 @@ async fn invalid_input_never_sends_and_diagnostics_redact() {
|
||||
Err(MutationError::InvalidRequest)
|
||||
));
|
||||
}
|
||||
for url in ["data:image/png;base64,", "data:,", "data:no-comma"] {
|
||||
let input = AddUserMessageRequest {
|
||||
input: vec![UserMessageInput::Image {
|
||||
image: ImageReference::Url(url.into()),
|
||||
detail: None,
|
||||
}],
|
||||
..request()
|
||||
};
|
||||
assert!(matches!(
|
||||
fixture.client.add_user_message(&input).await,
|
||||
Err(MutationError::InvalidRequest)
|
||||
));
|
||||
}
|
||||
let input = InterruptTurnRequest {
|
||||
thread_id: "private".into(),
|
||||
turn_id: "".into(),
|
||||
@@ -239,6 +373,91 @@ async fn invalid_input_never_sends_and_diagnostics_redact() {
|
||||
}
|
||||
assert!(fixture.requests.try_recv().is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn image_preflight_applies_exact_encoded_envelope_boundary() {
|
||||
let cap = 4 * 1024 * 1024 - 256 * 1024;
|
||||
for extra in [0, 1] {
|
||||
// With these IDs and settings, the nested wire envelope adds 38 bytes.
|
||||
let url = format!("data:,{}", "A".repeat(cap - 38 - "data:,".len() + extra));
|
||||
let expected = [
|
||||
message(/*tag*/ 4, b"m"),
|
||||
message(
|
||||
/*tag*/ 11,
|
||||
&message(
|
||||
/*tag*/ 1,
|
||||
&message(
|
||||
/*tag*/ 2,
|
||||
&message(/*tag*/ 1, &message(/*tag*/ 2, url.as_bytes())),
|
||||
),
|
||||
),
|
||||
),
|
||||
message(/*tag*/ 23, b"t"),
|
||||
vec![0xd0, 1, 1, 0xf0, 1, 3],
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(expected.len(), cap + extra);
|
||||
let mut fixture = fixture(response(
|
||||
vec![frame(&admitted(/*outcome*/ 3))],
|
||||
tonic::Status::ok(""),
|
||||
))
|
||||
.await;
|
||||
let input = vec![UserMessageInput::Image {
|
||||
image: ImageReference::Url(url),
|
||||
detail: None,
|
||||
}];
|
||||
let result = fixture
|
||||
.client
|
||||
.add_user_message(&AddUserMessageRequest { input, ..request() })
|
||||
.await;
|
||||
if extra == 0 {
|
||||
result.unwrap();
|
||||
assert_request(&mut fixture, "AddUserMessage", &expected).await;
|
||||
} else {
|
||||
assert!(matches!(result, Err(MutationError::InvalidRequest)));
|
||||
assert!(fixture.requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plain_text_retains_limit_above_image_forwarding_cap() {
|
||||
let mut fixture = fixture(response(
|
||||
vec![frame(&admitted(/*outcome*/ 3))],
|
||||
tonic::Status::ok(""),
|
||||
))
|
||||
.await;
|
||||
let text = "x".repeat(4 * 1024 * 1024);
|
||||
let input = vec![UserMessageInput::Text {
|
||||
text: text.clone(),
|
||||
text_elements: vec![],
|
||||
}];
|
||||
let result = fixture
|
||||
.client
|
||||
.add_user_message(&AddUserMessageRequest { input, ..request() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.outcome,
|
||||
AdmissionOutcome::Duplicate {
|
||||
turn_id: Some("u".into())
|
||||
}
|
||||
);
|
||||
let expected = [
|
||||
message(/*tag*/ 4, b"m"),
|
||||
message(
|
||||
/*tag*/ 11,
|
||||
&message(
|
||||
/*tag*/ 1,
|
||||
&message(/*tag*/ 1, &message(/*tag*/ 1, text.as_bytes())),
|
||||
),
|
||||
),
|
||||
message(/*tag*/ 23, b"t"),
|
||||
vec![0xd0, 1, 1, 0xf0, 1, 3],
|
||||
]
|
||||
.concat();
|
||||
assert_request(&mut fixture, "AddUserMessage", &expected).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_pending_submission_does_not_send_a_retry_or_interrupt() {
|
||||
let (sender, dropped) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Native plain-text submission and interruption envelopes; omitted settings inherit thread config.
|
||||
//! Native text/image submission and interruption envelopes; omitted settings inherit thread config.
|
||||
use prost::Message;
|
||||
|
||||
#[derive(Clone, Message)]
|
||||
@@ -30,8 +30,8 @@ pub(super) struct UserInput {
|
||||
pub(super) enum Value {
|
||||
#[prost(message, tag = "1")]
|
||||
Text(Text),
|
||||
#[prost(bytes, tag = "2")]
|
||||
Image(Vec<u8>),
|
||||
#[prost(message, tag = "2")]
|
||||
Image(Image),
|
||||
#[prost(bytes, tag = "3")]
|
||||
LocalImage(Vec<u8>),
|
||||
#[prost(bytes, tag = "4")]
|
||||
@@ -47,6 +47,47 @@ pub(super) enum Value {
|
||||
pub(super) struct Text {
|
||||
#[prost(string, optional, tag = "1")]
|
||||
pub text: Option<String>,
|
||||
#[prost(message, optional, tag = "2")]
|
||||
pub text_elements: Option<TextElements>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct TextElements {
|
||||
#[prost(message, repeated, tag = "1")]
|
||||
pub values: Vec<TextElement>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct TextElement {
|
||||
#[prost(message, optional, tag = "1")]
|
||||
pub byte_range: Option<ByteRange>,
|
||||
#[prost(string, optional, tag = "2")]
|
||||
pub placeholder: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct ByteRange {
|
||||
#[prost(uint64, optional, tag = "1")]
|
||||
pub end: Option<u64>,
|
||||
#[prost(uint64, optional, tag = "2")]
|
||||
pub start: Option<u64>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct Image {
|
||||
#[prost(oneof = "ImageValue", tags = "1, 2")]
|
||||
pub value: Option<ImageValue>,
|
||||
}
|
||||
#[derive(Clone, prost::Oneof)]
|
||||
pub(super) enum ImageValue {
|
||||
#[prost(message, tag = "1")]
|
||||
Url(ImageReference),
|
||||
#[prost(message, tag = "2")]
|
||||
FileId(ImageReference),
|
||||
}
|
||||
// Both native image alternatives have the same field layout.
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct ImageReference {
|
||||
#[prost(int32, optional, tag = "1")]
|
||||
pub detail: Option<i32>,
|
||||
#[prost(string, optional, tag = "2")]
|
||||
pub value: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Message)]
|
||||
pub(super) struct AddResponse {
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::chatwidget::ClipboardPasteContext;
|
||||
use crate::chatwidget::ExternalEditorState;
|
||||
use crate::chatwidget::ReplayKind;
|
||||
use crate::chatwidget::ThreadInputState;
|
||||
@@ -220,6 +221,9 @@ mod backend_events;
|
||||
pub(crate) mod backend_selection;
|
||||
pub(crate) mod backend_sessions;
|
||||
mod background_requests;
|
||||
mod cloud_switch_wait;
|
||||
#[cfg(test)]
|
||||
pub(crate) use cloud_switch_wait::wait_for_cloud_switch;
|
||||
mod composer_hints;
|
||||
mod config_panel;
|
||||
mod config_persistence;
|
||||
@@ -1313,7 +1317,9 @@ impl App {
|
||||
self.app_event_tx.send(AppEvent::LaunchExternalEditor);
|
||||
}
|
||||
}
|
||||
TuiEvent::Mouse(mouse) => self.start_right_click_paste(tui, mouse),
|
||||
TuiEvent::Mouse(mouse) => {
|
||||
self.start_right_click_paste(tui, mouse, ClipboardPasteContext::Composer);
|
||||
}
|
||||
TuiEvent::FocusLost => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
//! popup-specific handler if a popup is visible and otherwise to
|
||||
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
|
||||
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
|
||||
//! A pending backend handoff uses the basic editor and paste detection while holding submission.
|
||||
//! Redraws preserve the paste detector's window so trailing keys stay with the outgoing composer.
|
||||
//! Fresh Vim drafts start in Insert; Normal `/` and `?` search the composer.
|
||||
//! On an empty draft, a standalone Normal `/` enters Insert and opens command completion.
|
||||
//! Backspace on an empty Vim search query cancels search and any pending operator.
|
||||
@@ -290,6 +292,8 @@
|
||||
//! pasted text. This preserves the order when a paste arrives before the next burst-flush tick;
|
||||
//! buffered keys have already been classified and go directly through `apply_paste`.
|
||||
//! Image detection probes only regular files. Pasted device and FIFO paths stay literal.
|
||||
//! - Accepted clipboard images during Cloud handoff settle buffered typing before inserting their
|
||||
//! placeholder. This edit keeps a newly settled leading bang literal if navigation fails.
|
||||
//! - Startup recovery previews held keys with the live Vim Replace policy without consuming them.
|
||||
//! Buffered pastes remain literal in recovery so large-paste and image classification stays with
|
||||
//! the live paste path.
|
||||
@@ -710,6 +714,7 @@ pub(super) struct ComposerDraft {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ComposerDraftSnapshot {
|
||||
pub(crate) vim_normal_mode: bool,
|
||||
pub(crate) text: String,
|
||||
pub(crate) shell_mode: Option<bool>,
|
||||
pub(crate) cursor: usize,
|
||||
|
||||
@@ -20,6 +20,7 @@ impl ChatComposer {
|
||||
attachments.set_remote_image_urls(draft.remote_image_urls.clone(), &mut textarea);
|
||||
attachments.reset_local_images(draft.local_image_paths, &mut textarea);
|
||||
ComposerDraftSnapshot {
|
||||
vim_normal_mode: self.draft.textarea.is_vim_normal_mode(),
|
||||
text: draft.text,
|
||||
shell_mode: self
|
||||
.config
|
||||
|
||||
@@ -93,6 +93,23 @@ impl ChatComposer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::bottom_pane) fn paste_handoff_deadline(&self) -> Option<Instant> {
|
||||
if self.draft.disable_paste_burst {
|
||||
return None;
|
||||
}
|
||||
self.draft.paste_burst.handoff_deadline()
|
||||
}
|
||||
|
||||
/// Display flushed text without losing the window for its trailing paste keys.
|
||||
pub(in crate::bottom_pane) fn flush_handoff_paste(&mut self, now: Instant) -> bool {
|
||||
let deadline = self.paste_handoff_deadline();
|
||||
let flushed = self.handle_paste_burst_flush(now);
|
||||
if flushed && let Some(deadline) = deadline.filter(|deadline| *deadline >= now) {
|
||||
self.draft.paste_burst.preserve_window_until(deadline);
|
||||
}
|
||||
flushed
|
||||
}
|
||||
|
||||
/// Enable or disable paste-burst handling.
|
||||
///
|
||||
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
|
||||
@@ -235,11 +252,13 @@ impl ChatComposer {
|
||||
}
|
||||
|
||||
pub(super) fn handle_paste_tab(&mut self, key: KeyEvent, now: Instant) -> bool {
|
||||
if key.code != KeyCode::Tab
|
||||
|| !key.modifiers.is_empty()
|
||||
|| self.draft.disable_paste_burst
|
||||
|| !self.draft.textarea.allows_paste_burst()
|
||||
{
|
||||
key.code == KeyCode::Tab
|
||||
&& key.modifiers.is_empty()
|
||||
&& self.append_recent_paste_separator('\t', now)
|
||||
}
|
||||
|
||||
pub(super) fn append_recent_paste_separator(&mut self, separator: char, now: Instant) -> bool {
|
||||
if self.draft.disable_paste_burst || !self.draft.textarea.allows_paste_burst() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -247,7 +266,7 @@ impl ChatComposer {
|
||||
if self
|
||||
.draft
|
||||
.paste_burst
|
||||
.append_control_char_if_active('\t', now)
|
||||
.append_control_char_if_active(separator, now)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -264,7 +283,7 @@ impl ChatComposer {
|
||||
return self
|
||||
.draft
|
||||
.paste_burst
|
||||
.append_control_char_if_active('\t', now);
|
||||
.append_control_char_if_active(separator, now);
|
||||
}
|
||||
|
||||
false
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! Unavailable threads also allow recovery and other local commands.
|
||||
//! Connected unavailable threads retain the empty-prompt agents shortcut.
|
||||
//! Paste Enter handling is shared with normal submission so buffered newlines survive both paths.
|
||||
//! In the draft-only handoff, a recent paste prefix can preserve a raw Enter as a newline.
|
||||
//! The submit path still requires an established paste before treating Enter as a newline.
|
||||
//! Recovery commands must occupy one line and be visible before the submit key expands pastes.
|
||||
//! Offline draft edits also consume the Astra sparkle opportunity before rendering.
|
||||
|
||||
@@ -11,6 +13,7 @@ use super::*;
|
||||
pub(crate) enum RestrictedInputMode {
|
||||
Disconnected,
|
||||
UnavailableThread,
|
||||
DraftHandoff,
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
@@ -60,6 +63,37 @@ impl ChatComposer {
|
||||
key: KeyEvent,
|
||||
mode: RestrictedInputMode,
|
||||
) -> InputResult {
|
||||
if mode == RestrictedInputMode::DraftHandoff {
|
||||
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
let now = tokio::time::Instant::now().into_std();
|
||||
self.flush_handoff_paste(now);
|
||||
// Completion does not own keys during the handoff.
|
||||
self.popups.active = ActivePopup::None;
|
||||
if self.draft.textarea.wants_vim_search_key(key) {
|
||||
self.edit_restricted_draft(key);
|
||||
} else if self.handle_vim_history_key(key) {
|
||||
self.draft.textarea_state.get_mut().follow_cursor();
|
||||
} else {
|
||||
// Enter cannot submit here. Preserve a possible legacy paste
|
||||
// newline after a directly inserted prefix, as we do for Tab.
|
||||
let paste_separator = (key.code == KeyCode::Enter
|
||||
&& key.modifiers.is_empty()
|
||||
&& (self.append_paste_newline(now)
|
||||
|| self.append_recent_paste_separator('\n', now)))
|
||||
|| self.handle_paste_tab(key, now);
|
||||
if !paste_separator
|
||||
&& !self.submit_keys.is_pressed(key)
|
||||
&& !self.queue_keys.is_pressed(key)
|
||||
{
|
||||
self.edit_restricted_draft(key);
|
||||
}
|
||||
}
|
||||
// A paste flush can open a popup before the edit changes its text.
|
||||
// Dismiss it before ordinary input resumes, even without a redraw.
|
||||
self.popups.active = ActivePopup::None;
|
||||
}
|
||||
return InputResult::None;
|
||||
}
|
||||
if mode == RestrictedInputMode::UnavailableThread && self.handle_agents_navigation(key) {
|
||||
return InputResult::None;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,56 @@ use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn draft_handoff_preserves_newline_after_short_unicode_prefix() {
|
||||
let (mut composer, _) = new_test_composer();
|
||||
for key in [KeyCode::Char('猫'), KeyCode::Enter, KeyCode::Char('犬')] {
|
||||
assert_eq!(
|
||||
composer.handle_restricted_key(key.into(), RestrictedInputMode::DraftHandoff),
|
||||
InputResult::None,
|
||||
);
|
||||
}
|
||||
tokio::time::advance(Duration::from_millis(/*millis*/ 100)).await;
|
||||
composer.flush_handoff_paste(tokio::time::Instant::now().into_std());
|
||||
composer.handle_restricted_key(KeyCode::Enter.into(), RestrictedInputMode::DraftHandoff);
|
||||
insta::assert_snapshot!(composer.current_text(), @"猫
|
||||
犬");
|
||||
|
||||
let (mut composer, _) = new_test_composer();
|
||||
composer.handle_key_event(KeyCode::Char('猫').into());
|
||||
let (result, _) = composer.handle_key_event(KeyCode::Enter.into());
|
||||
assert!(matches!(result, InputResult::Submitted { text, .. } if text == "猫"));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn handoff_edit_discards_stale_command_before_ordinary_enter() {
|
||||
for bracketed_paste in [true, false] {
|
||||
let (mut composer, _) = new_test_composer();
|
||||
if bracketed_paste {
|
||||
composer.handle_paste("/quit".into());
|
||||
assert!(matches!(composer.popups.active, ActivePopup::Command(_)));
|
||||
} else {
|
||||
for ch in "/quit".chars() {
|
||||
composer.handle_restricted_key(
|
||||
KeyCode::Char(ch).into(),
|
||||
RestrictedInputMode::DraftHandoff,
|
||||
);
|
||||
}
|
||||
assert!(composer.draft.paste_burst.is_active());
|
||||
}
|
||||
|
||||
composer.handle_restricted_key(
|
||||
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||||
RestrictedInputMode::DraftHandoff,
|
||||
);
|
||||
assert_eq!(composer.current_text(), "");
|
||||
|
||||
// A failed handoff can return to ordinary input before a redraw.
|
||||
let (result, _) = composer.handle_key_event(KeyCode::Enter.into());
|
||||
assert_eq!(result, InputResult::None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_delete_reveals_the_edited_row_without_moving_the_caret() {
|
||||
let (mut composer, _) = new_test_composer();
|
||||
|
||||
@@ -12,6 +12,7 @@ use pretty_assertions::assert_eq;
|
||||
|
||||
use super::super::super::chat_composer_history::HistoryEntry;
|
||||
use super::super::ChatComposer;
|
||||
use super::super::ComposerDraftSnapshot;
|
||||
use super::super::InputResult;
|
||||
use super::super::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use super::super::PasteBurst;
|
||||
@@ -590,7 +591,13 @@ fn undo_and_redo_restore_deleted_image_attachment() {
|
||||
ctrl_r(&mut composer);
|
||||
escape(&mut composer);
|
||||
let _ = composer.handle_key_event(KeyEvent::from(KeyCode::Backspace));
|
||||
assert_eq!(composer.draft_snapshot(), before);
|
||||
assert_eq!(
|
||||
composer.draft_snapshot(),
|
||||
ComposerDraftSnapshot {
|
||||
vim_normal_mode: false,
|
||||
..before.clone()
|
||||
}
|
||||
);
|
||||
escape(&mut composer);
|
||||
composer.draft.textarea.set_cursor("alpha ".len());
|
||||
keys(&mut composer, "x");
|
||||
|
||||
@@ -870,6 +870,14 @@ impl BottomPane {
|
||||
key: KeyEvent,
|
||||
mode: RestrictedInputMode,
|
||||
) -> InputResult {
|
||||
if mode == RestrictedInputMode::DraftHandoff {
|
||||
let result = self.composer.handle_restricted_key(key, mode);
|
||||
if Self::is_composer_activity_key(key) {
|
||||
self.record_composer_activity_at(Instant::now());
|
||||
}
|
||||
self.request_redraw();
|
||||
return result;
|
||||
}
|
||||
self.view_stack.clear();
|
||||
self.warnings_view = None;
|
||||
self.delayed_approval_requests.clear();
|
||||
@@ -2385,6 +2393,12 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Settle typing before an accepted handoff image without starting shell mode.
|
||||
pub(crate) fn settle_before_handoff_image(&mut self) {
|
||||
self.composer
|
||||
.flush_pending_input_with_shell_prefix(ShellPrefixHandling::Literal);
|
||||
}
|
||||
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
if !self.has_active_view() {
|
||||
self.composer.attach_image(path);
|
||||
|
||||
@@ -358,6 +358,22 @@ impl PasteBurst {
|
||||
.is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL)
|
||||
}
|
||||
|
||||
/// Keep input in this composer while keys can still belong to its paste.
|
||||
pub(super) fn handoff_deadline(&self) -> Option<Instant> {
|
||||
let interval = if self.is_active_internal() {
|
||||
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
|
||||
} else {
|
||||
PASTE_BURST_CHAR_INTERVAL
|
||||
};
|
||||
self.last_plain_char_time
|
||||
.map(|last_key| last_key + interval)
|
||||
.max(self.burst_window_until)
|
||||
}
|
||||
|
||||
pub(super) fn preserve_window_until(&mut self, deadline: Instant) {
|
||||
self.burst_window_until = self.burst_window_until.max(Some(deadline));
|
||||
}
|
||||
|
||||
/// Keep the burst window alive.
|
||||
pub fn extend_window(&mut self, now: Instant) {
|
||||
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
||||
|
||||
@@ -127,6 +127,15 @@ impl BottomPane {
|
||||
!self.delayed_approval_requests.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn composer_paste_deadline(&self) -> Option<Instant> {
|
||||
self.composer.paste_handoff_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn flush_handoff_paste_if_due(&mut self) -> bool {
|
||||
self.composer
|
||||
.flush_handoff_paste(tokio::time::Instant::now().into_std())
|
||||
}
|
||||
|
||||
/// Preserve buffered composer input before its owner or input mode changes.
|
||||
pub(crate) fn flush_composer_paste_burst(&mut self) {
|
||||
if self.composer.is_in_paste_burst() {
|
||||
|
||||
@@ -305,6 +305,7 @@ mod session_flow;
|
||||
mod session_header;
|
||||
use self::session_header::SessionHeader;
|
||||
mod clipboard;
|
||||
pub(crate) use clipboard::ClipboardPasteContext;
|
||||
mod copy_picker;
|
||||
mod hook_lifecycle;
|
||||
mod hooks;
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard_copy::worker::CopyResult;
|
||||
use crate::clipboard_paste::PasteImageError;
|
||||
|
||||
const IMAGE_PASTE_REPEAT_WINDOW: Duration = Duration::from_millis(/*millis*/ 250);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ClipboardPasteContext {
|
||||
Composer,
|
||||
DraftHandoff,
|
||||
}
|
||||
|
||||
pub(super) enum PendingCopy {
|
||||
Message(u64, String),
|
||||
Selection(u64),
|
||||
@@ -13,7 +20,11 @@ pub(super) enum PendingCopy {
|
||||
|
||||
impl ChatWidget {
|
||||
/// Recognize image paste shortcuts, including presses consumed by repeat suppression.
|
||||
pub(super) fn image_paste_key_action(&mut self, key: KeyEvent) -> Option<KeyEventAction> {
|
||||
pub(super) fn image_paste_key_action(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
context: ClipboardPasteContext,
|
||||
) -> Option<KeyEventAction> {
|
||||
let shift_insert = cfg!(windows) && key_hint::shift(KeyCode::Insert).is_press(key);
|
||||
let image_paste = key.kind == KeyEventKind::Press
|
||||
&& ((matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'v'))
|
||||
@@ -27,23 +38,38 @@ impl ChatWidget {
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if Instant::now() < self.suppress_image_paste_until {
|
||||
self.suppress_image_paste_until = Instant::now() + IMAGE_PASTE_REPEAT_WINDOW;
|
||||
let now = Instant::now();
|
||||
let suppressed = now < self.suppress_image_paste_until;
|
||||
self.suppress_image_paste_until = now + IMAGE_PASTE_REPEAT_WINDOW;
|
||||
if suppressed {
|
||||
return Some(KeyEventAction::None);
|
||||
}
|
||||
self.suppress_image_paste_until = Instant::now() + IMAGE_PASTE_REPEAT_WINDOW;
|
||||
let text_fallback = (cfg!(windows)
|
||||
&& (key.modifiers == KeyModifiers::CONTROL || shift_insert))
|
||||
.then_some(key.code);
|
||||
if text_fallback.is_some() {
|
||||
if text_fallback.is_some() && context == ClipboardPasteContext::Composer {
|
||||
self.bottom_pane.prepare_composer_clipboard_paste();
|
||||
}
|
||||
Some(KeyEventAction::PasteImage { text_fallback })
|
||||
Some(KeyEventAction::PasteImage {
|
||||
text_fallback,
|
||||
context,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn paste_image(&mut self) -> Result<(), crate::clipboard_paste::PasteImageError> {
|
||||
pub(crate) fn paste_image(
|
||||
&mut self,
|
||||
context: ClipboardPasteContext,
|
||||
text_fallback: Option<KeyCode>,
|
||||
) -> Result<(), PasteImageError> {
|
||||
let result = paste_image_to_temp_png();
|
||||
self.suppress_image_paste_until = Instant::now() + IMAGE_PASTE_REPEAT_WINDOW;
|
||||
if context == ClipboardPasteContext::DraftHandoff
|
||||
&& text_fallback.is_some()
|
||||
&& matches!(&result, Err(PasteImageError::NoImage(_)))
|
||||
{
|
||||
// Settle typing before capturing the asynchronous text target.
|
||||
self.bottom_pane.prepare_composer_clipboard_paste();
|
||||
}
|
||||
let (path, info) = result?;
|
||||
tracing::debug!(
|
||||
"pasted image size={}x{} format={}",
|
||||
@@ -51,7 +77,7 @@ impl ChatWidget {
|
||||
info.height,
|
||||
info.encoded_format.label()
|
||||
);
|
||||
self.attach_image(path);
|
||||
self.attach_image(path, context);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,15 @@ impl ChatWidget {
|
||||
draft
|
||||
}
|
||||
|
||||
/// Continue an active Cloud-switch draft without undoing an accepted Vim Escape.
|
||||
pub(crate) fn restore_cloud_switch_draft(&mut self, draft: ComposerDraftSnapshot) {
|
||||
let continue_vim_normal_mode = draft.vim_normal_mode;
|
||||
self.restore_startup_draft(draft);
|
||||
if continue_vim_normal_mode {
|
||||
self.bottom_pane.set_vim_enabled(/*enabled*/ true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the exact draft entered before the fully initialized composer became available.
|
||||
pub(crate) fn restore_startup_draft(&mut self, draft: ComposerDraftSnapshot) {
|
||||
self.bottom_pane
|
||||
|
||||
@@ -12,7 +12,10 @@ use crate::clipboard_copy::worker::CopyResult;
|
||||
pub(crate) enum KeyEventAction {
|
||||
None,
|
||||
CopyLastResponse(Arc<str>),
|
||||
PasteImage { text_fallback: Option<KeyCode> },
|
||||
PasteImage {
|
||||
text_fallback: Option<KeyCode>,
|
||||
context: ClipboardPasteContext,
|
||||
},
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
@@ -250,12 +253,7 @@ impl ChatWidget {
|
||||
self.on_ctrl_c();
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
|
||||
key if Self::is_ctrl_d_press(key) => {
|
||||
if self.on_ctrl_d() {
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
@@ -264,7 +262,9 @@ impl ChatWidget {
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
_ => {
|
||||
if let Some(action) = self.image_paste_key_action(key_event) {
|
||||
if let Some(action) =
|
||||
self.image_paste_key_action(key_event, ClipboardPasteContext::Composer)
|
||||
{
|
||||
return action;
|
||||
}
|
||||
if key_event.kind == KeyEventKind::Press {
|
||||
@@ -363,12 +363,12 @@ impl ChatWidget {
|
||||
KeyEventAction::None
|
||||
}
|
||||
|
||||
/// Attach a local image to the composer when the active model supports image inputs.
|
||||
/// Attach a local image, deferring model validation while the draft awaits its destination.
|
||||
///
|
||||
/// When the model does not advertise image support, we keep the draft unchanged and surface a
|
||||
/// warning event so users can switch models or remove attachments.
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf) {
|
||||
if !self.current_model_supports_images() {
|
||||
/// Ordinary paste still checks the active model. Handoff only retains the attachment locally;
|
||||
/// submission validates the destination model and backend before admission.
|
||||
pub(crate) fn attach_image(&mut self, path: PathBuf, context: ClipboardPasteContext) {
|
||||
if context == ClipboardPasteContext::Composer && !self.current_model_supports_images() {
|
||||
self.add_to_history(history_cell::new_warning_event(
|
||||
self.image_inputs_not_supported_message(),
|
||||
));
|
||||
@@ -376,6 +376,9 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
tracing::info!("attach_image path={path:?}");
|
||||
if context == ClipboardPasteContext::DraftHandoff && !self.has_active_view() {
|
||||
self.bottom_pane.settle_before_handoff_image();
|
||||
}
|
||||
self.bottom_pane.attach_image(path);
|
||||
self.refresh_startup_recovery();
|
||||
self.request_redraw();
|
||||
@@ -608,6 +611,11 @@ impl ChatWidget {
|
||||
self.finish_paste_burst_tick(frame_requester, flushed)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_handoff_paste_tick(&mut self, frame_requester: FrameRequester) -> bool {
|
||||
let flushed = self.bottom_pane.flush_handoff_paste_if_due();
|
||||
self.finish_paste_burst_tick(frame_requester, flushed)
|
||||
}
|
||||
|
||||
fn finish_paste_burst_tick(&mut self, frame_requester: FrameRequester, flushed: bool) -> bool {
|
||||
if flushed {
|
||||
self.refresh_startup_recovery();
|
||||
@@ -700,11 +708,23 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_ctrl_d_press(key: KeyEvent) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d')
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles a Ctrl+D press at the chat-widget layer.
|
||||
///
|
||||
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
|
||||
/// Otherwise it should be routed to the active view and not attempt to quit.
|
||||
fn on_ctrl_d(&mut self) -> bool {
|
||||
pub(super) fn on_ctrl_d(&mut self) -> bool {
|
||||
let key = key_hint::ctrl(KeyCode::Char('d'));
|
||||
if !DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED {
|
||||
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active()
|
||||
|
||||
@@ -196,9 +196,39 @@ impl ChatWidget {
|
||||
self.bottom_pane.active_view_id() == Some(view_id)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_restricted_key(&mut self, key: KeyEvent, mode: RestrictedInputMode) {
|
||||
pub(crate) fn composer_paste_deadline(&self) -> Option<Instant> {
|
||||
self.bottom_pane.composer_paste_deadline()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_restricted_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
mode: RestrictedInputMode,
|
||||
) -> KeyEventAction {
|
||||
if self.external_writer_view {
|
||||
return;
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
if mode == RestrictedInputMode::DraftHandoff {
|
||||
// Buffered typing also belongs to the draft. Queue the usual quit only when
|
||||
// Ctrl+D has no draft or active input surface to edit.
|
||||
if Self::is_ctrl_d_press(key)
|
||||
&& self.composer_is_empty()
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
&& self.on_ctrl_d()
|
||||
{
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
if !self.startup_submission_has_protected_input()
|
||||
&& !self.blocks_direct_input
|
||||
&& self.bottom_pane.composer_input_enabled()
|
||||
&& let Some(action) =
|
||||
self.image_paste_key_action(key, ClipboardPasteContext::DraftHandoff)
|
||||
{
|
||||
return action;
|
||||
}
|
||||
self.bottom_pane.handle_restricted_key(key, mode);
|
||||
self.refresh_startup_recovery();
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
let startup_key_handled = self.input_queue.startup_submission.is_some()
|
||||
&& !self.startup_submission_has_protected_input()
|
||||
@@ -210,7 +240,7 @@ impl ChatWidget {
|
||||
)]));
|
||||
}
|
||||
if startup_key_handled {
|
||||
return;
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
if self.input_queue.startup_submission.is_some()
|
||||
&& !self.startup_submission_has_protected_input()
|
||||
@@ -218,10 +248,10 @@ impl ChatWidget {
|
||||
// Cursor movement must preserve the confirmed draft's paste provenance.
|
||||
let result = self.bottom_pane.handle_key_event(key);
|
||||
self.handle_composer_input_result(result, /*had_modal_or_popup*/ false);
|
||||
return;
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
if self.handle_question_key(key) {
|
||||
return;
|
||||
return KeyEventAction::None;
|
||||
}
|
||||
if key.kind == KeyEventKind::Press && self.chat_keymap.edit_queued_message.is_pressed(key) {
|
||||
if let Some(composer) = self.pop_latest_queued_composer_state() {
|
||||
@@ -243,6 +273,7 @@ impl ChatWidget {
|
||||
let result = self.bottom_pane.handle_restricted_key(key, mode);
|
||||
self.handle_composer_input_result(result, /*had_modal_or_popup*/ false);
|
||||
}
|
||||
KeyEventAction::None
|
||||
}
|
||||
|
||||
pub(crate) fn reconnect_failed(&mut self) {
|
||||
|
||||
@@ -417,6 +417,99 @@ async fn parent_owned_thread_preserves_queued_input_before_draining() {
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handoff_image_survives_model_rejection_and_submission_failure() {
|
||||
let (mut source, _events, mut source_ops) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
source.thread_id = Some(ThreadId::new());
|
||||
let mut models = source.model_catalog.try_list_models().unwrap();
|
||||
for model in &mut models {
|
||||
model
|
||||
.input_modalities
|
||||
.retain(|modality| *modality != InputModality::Image);
|
||||
}
|
||||
source.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||
source.sync_image_paste_enabled();
|
||||
let path = PathBuf::from("image.png");
|
||||
let action = source.handle_restricted_key(
|
||||
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
|
||||
RestrictedInputMode::DraftHandoff,
|
||||
);
|
||||
let KeyEventAction::PasteImage { context, .. } = action else {
|
||||
panic!("expected handoff image paste");
|
||||
};
|
||||
source.attach_image(path.clone(), context);
|
||||
let draft = source.composer_draft_snapshot();
|
||||
assert_eq!(
|
||||
draft.local_images,
|
||||
vec![LocalImageAttachment {
|
||||
placeholder: "[Image #1]".into(),
|
||||
path,
|
||||
}],
|
||||
);
|
||||
insta::assert_snapshot!(source.bottom_pane.composer_text(), @"[Image #1]");
|
||||
source.handle_restricted_key(KeyCode::Enter.into(), RestrictedInputMode::DraftHandoff);
|
||||
assert_no_submit_op(&mut source_ops);
|
||||
|
||||
// A failed switch leaves the source active: submission must reject but retain the image.
|
||||
source.handle_key_event(KeyCode::Enter.into());
|
||||
assert_no_submit_op(&mut source_ops);
|
||||
assert_eq!(
|
||||
source.bottom_pane.composer_draft_snapshot().local_images,
|
||||
draft.local_images
|
||||
);
|
||||
|
||||
let (mut destination, _events, mut destination_ops) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
destination.thread_id = Some(ThreadId::new());
|
||||
destination.restore_startup_draft(draft.clone());
|
||||
let mut supported_models = destination.model_catalog.try_list_models().unwrap();
|
||||
let current_model = destination.current_model().to_string();
|
||||
let model = supported_models
|
||||
.iter_mut()
|
||||
.find(|model| model.model == current_model)
|
||||
.expect("current model");
|
||||
if !model.input_modalities.contains(&InputModality::Image) {
|
||||
model.input_modalities.push(InputModality::Image);
|
||||
}
|
||||
let mut unsupported_models = supported_models.clone();
|
||||
for model in &mut unsupported_models {
|
||||
model
|
||||
.input_modalities
|
||||
.retain(|modality| *modality != InputModality::Image);
|
||||
}
|
||||
destination.model_catalog = Arc::new(ModelCatalog::new(unsupported_models));
|
||||
destination.handle_key_event(KeyCode::Enter.into());
|
||||
assert_no_submit_op(&mut destination_ops);
|
||||
assert_eq!(
|
||||
destination
|
||||
.bottom_pane
|
||||
.composer_draft_snapshot()
|
||||
.local_images,
|
||||
draft.local_images
|
||||
);
|
||||
|
||||
// A capable destination admits the image; a subsequent submission failure restores it.
|
||||
destination.model_catalog = Arc::new(ModelCatalog::new(supported_models));
|
||||
destination.handle_key_event(KeyCode::Enter.into());
|
||||
assert_matches!(next_submit_op(&mut destination_ops), Op::UserTurn { .. });
|
||||
let client_id = destination
|
||||
.input_queue
|
||||
.pending_user_message_client_id
|
||||
.clone()
|
||||
.expect("pending submission");
|
||||
destination.restore_rejected_user_message(&client_id, "submission failed".into());
|
||||
assert_eq!(
|
||||
destination
|
||||
.bottom_pane
|
||||
.composer_draft_snapshot()
|
||||
.local_images,
|
||||
draft.local_images
|
||||
);
|
||||
insta::assert_snapshot!(destination.bottom_pane.composer_text(), @"[Image #1]");
|
||||
assert_no_submit_op(&mut destination_ops);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn local_clipboard_image_does_not_turn_a_held_bang_into_a_shell_command() {
|
||||
let (mut chat, _events, mut operations) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -427,11 +520,11 @@ async fn local_clipboard_image_does_not_turn_a_held_bang_into_a_shell_command()
|
||||
assert_eq!(chat.bottom_pane.composer_recovery_snapshot().text, "!");
|
||||
|
||||
let paste = chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
|
||||
let KeyEventAction::PasteImage { .. } = paste else {
|
||||
let KeyEventAction::PasteImage { context, .. } = paste else {
|
||||
panic!("expected a clipboard image request");
|
||||
};
|
||||
let image_path = PathBuf::from("image.png");
|
||||
chat.attach_image(image_path.clone());
|
||||
chat.attach_image(image_path.clone(), context);
|
||||
chat.handle_key_event(KeyCode::Enter.into());
|
||||
|
||||
let draft = chat.bottom_pane.composer_recovery_snapshot();
|
||||
|
||||
@@ -2592,7 +2592,7 @@ async fn copy_shortcut_can_be_remapped() {
|
||||
let paste = KeyEvent::new(code, modifiers);
|
||||
assert_matches!(
|
||||
chat.handle_key_event(paste),
|
||||
crate::chatwidget::KeyEventAction::PasteImage { text_fallback }
|
||||
crate::chatwidget::KeyEventAction::PasteImage { text_fallback, .. }
|
||||
if text_fallback == (cfg!(windows) && modifiers == KeyModifiers::CONTROL)
|
||||
.then_some(code)
|
||||
);
|
||||
@@ -2609,7 +2609,8 @@ async fn copy_shortcut_can_be_remapped() {
|
||||
assert_matches!(
|
||||
chat.handle_key_event(shift_insert),
|
||||
KeyEventAction::PasteImage {
|
||||
text_fallback: Some(KeyCode::Insert)
|
||||
text_fallback: Some(KeyCode::Insert),
|
||||
..
|
||||
}
|
||||
);
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "q");
|
||||
|
||||
@@ -311,6 +311,21 @@ impl ClipboardWorker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The original budget follows the read until its owner consumes the result.
|
||||
pub(crate) fn text_read_deadline(&self, request: &Arc<()>) -> Option<Instant> {
|
||||
let request = Arc::downgrade(request);
|
||||
self.pending_read
|
||||
.as_ref()
|
||||
.filter(|read| read.owner.ptr_eq(&request))
|
||||
.map(|read| read.deadline)
|
||||
.or_else(|| {
|
||||
self.read_result
|
||||
.as_ref()
|
||||
.filter(|(_, owner, _)| owner.ptr_eq(&request))
|
||||
.map(|(deadline, _, _)| *deadline)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn take_text_result(&mut self) -> Option<Result<String, String>> {
|
||||
let (deadline, owner, result) = self.read_result.take()?;
|
||||
(owner.strong_count() > 0).then(|| {
|
||||
|
||||
@@ -13,6 +13,46 @@ use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cloud_config_completion_does_not_wait_for_the_clipboard() {
|
||||
use crate::app::test_support::ClipboardHandoff;
|
||||
use crate::app::test_support::wait_for_cloud_switch;
|
||||
|
||||
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
let (release, released) = mpsc::channel::<()>();
|
||||
tui.clipboard
|
||||
.start(
|
||||
tui.frame_requester(),
|
||||
|_, _, _| unreachable!("read-only clipboard fixture"),
|
||||
move || {
|
||||
move |_| {
|
||||
released.recv().map_err(|error| error.to_string())?;
|
||||
Ok("clipboard draft".into())
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
app.start_clipboard_text_paste(&mut tui, PasteSource::Clipboard, Some(KeyCode::Char('v')));
|
||||
assert!(tui.clipboard.is_busy());
|
||||
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(/*millis*/ 1),
|
||||
wait_for_cloud_switch(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
std::future::ready(Ok::<(), std::convert::Infallible>(())),
|
||||
tokio_stream::pending(),
|
||||
ClipboardHandoff::ContinueEditing,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("Cloud can start while the clipboard request is blocked")
|
||||
.unwrap();
|
||||
assert!(tui.clipboard.is_busy());
|
||||
release.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() {
|
||||
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
|
||||
@@ -511,6 +551,89 @@ fn expired_text_read_rejects_backlog_and_discards_late_completion() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cloud_handoff_keeps_an_owned_clipboard_read_until_completion_or_input() {
|
||||
use crate::app::test_support::ClipboardHandoff;
|
||||
use codex_config::types::RightClickPaste;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
|
||||
let shortcut = Some(KeyCode::Char('v'));
|
||||
for (shortcut, cancellation) in [
|
||||
(None, None),
|
||||
(shortcut, None),
|
||||
(shortcut, Some(TuiEvent::FocusLost)),
|
||||
(shortcut, Some(TuiEvent::Key(KeyCode::Enter.into()))),
|
||||
] {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
|
||||
app.chat_widget.apply_external_edit("draft ".into());
|
||||
app.local_settings.tui.right_click_paste = RightClickPaste::On;
|
||||
app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned;
|
||||
tui.set_owned_screen(shortcut.is_none()).unwrap();
|
||||
let (requests, incoming) = mpsc::channel();
|
||||
tui.clipboard.requests = Some(requests);
|
||||
|
||||
let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
if shortcut.is_some() {
|
||||
// Start at the Windows NoImage fallback without accessing the host clipboard.
|
||||
app.start_clipboard_text_paste(&mut tui, PasteSource::Clipboard, shortcut);
|
||||
} else {
|
||||
events_tx
|
||||
.send(TuiEvent::Key(KeyCode::Char('x').into()))
|
||||
.unwrap();
|
||||
events_tx
|
||||
.send(TuiEvent::Mouse(MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: 1,
|
||||
row: 1,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
let (prepared_tx, prepared_rx) = tokio::sync::oneshot::channel();
|
||||
let events = tokio_stream::wrappers::UnboundedReceiverStream::new(events_rx);
|
||||
let expected = if shortcut.is_none() {
|
||||
"draft xpasted"
|
||||
} else if cancellation.is_some() {
|
||||
"draft "
|
||||
} else {
|
||||
"draft pasted"
|
||||
};
|
||||
{
|
||||
let wait = crate::app::wait_for_cloud_switch(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
prepared_rx,
|
||||
events,
|
||||
ClipboardHandoff::SettleBeforeNextView,
|
||||
);
|
||||
tokio::pin!(wait);
|
||||
assert!(futures::poll!(&mut wait).is_pending());
|
||||
let Request::Read { response, .. } = incoming.try_recv().expect("clipboard text read")
|
||||
else {
|
||||
panic!("expected a clipboard text read");
|
||||
};
|
||||
prepared_tx.send(()).unwrap();
|
||||
assert!(futures::poll!(&mut wait).is_pending());
|
||||
// Let the immediate terminal drain expire while the read is still outstanding.
|
||||
tokio::time::advance(Duration::from_millis(20)).await;
|
||||
assert!(futures::poll!(&mut wait).is_pending());
|
||||
events_tx.send(TuiEvent::Draw).unwrap();
|
||||
assert!(futures::poll!(&mut wait).is_pending());
|
||||
|
||||
if let Some(event) = cancellation {
|
||||
events_tx.send(event).unwrap();
|
||||
}
|
||||
response.send(Ok("pasted".into())).unwrap();
|
||||
events_tx.send(TuiEvent::Draw).unwrap();
|
||||
wait.await.unwrap();
|
||||
}
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_primary_is_published_before_read_but_does_not_preempt_explicit_copy() {
|
||||
let frames = FrameRequester::test_dummy();
|
||||
|
||||
@@ -9,6 +9,7 @@ use pretty_assertions::assert_eq;
|
||||
|
||||
fn snapshot(text: &str) -> ComposerDraftSnapshot {
|
||||
ComposerDraftSnapshot {
|
||||
vim_normal_mode: false,
|
||||
text: text.to_string(),
|
||||
shell_mode: None,
|
||||
cursor: text.len(),
|
||||
|
||||
Reference in New Issue
Block a user