Keep the TUI responsive during clipboard copies (#47847)

## Why

Native clipboard setup and writes can block, preventing the TUI from processing input or closing overlays while a copy is in progress.

## What changed

- Route clipboard copies through one session worker that retains native clipboard ownership. Keep terminal escape output on the UI thread.
- Show pending and busy status messages, reject additional copies without queuing them, and ask users to retry image paste after an active copy finishes.
- Abandon setup after five seconds and prevent late delivery. Keep the worker busy until the blocked call returns, and bound shutdown waiting for clipboard handoff.
- Match completions to their originating requests so stale results cannot clear newer selections or unexpectedly resume transcript following. Retain selections on automatic copies, failures, and unconfirmed delivery.

## Testing

Add regression tests for blocked backends, overlay exit, copy and paste ordering, setup expiry, bounded shutdown, completion status messages, and preservation of replaced or moved selections.

GitOrigin-RevId: 4ba1f8da258b9d0700b0edec9ac0ba699f536785
This commit is contained in:
Felipe Coury
2026-09-24 14:15:42 +00:00
committed by copyberry
parent 282cd7b019
commit 12fd929f72
38 changed files with 1238 additions and 444 deletions
+2
View File
@@ -233,6 +233,7 @@ mod misalignment_policy;
mod model_defaults;
mod new_session;
pub(crate) use new_session::has_launch_setting;
mod clipboard;
mod native_history;
mod owned_transcript;
mod pending_interactive_replay;
@@ -862,6 +863,7 @@ impl App {
app_server: &mut AppServerSession,
event: TuiEvent,
) -> Result<AppRunControl> {
self.finish_clipboard(tui);
if matches!(&event, TuiEvent::Key(_))
&& self.handle_composer_copy_event(tui, &event, tui::Tui::copy_transcript_selection)
{
+54
View File
@@ -0,0 +1,54 @@
//! Dispatch clipboard key actions in order and apply completions to their selection owner.
use super::*;
use crate::chatwidget::KeyEventAction;
use crate::clipboard_copy::CopyFormat;
impl App {
pub(super) fn handle_clipboard_key_action(
&mut self,
tui: &mut tui::Tui,
action: KeyEventAction,
) {
match action {
KeyEventAction::None => {}
KeyEventAction::CopyLastResponse(text) => {
let result = tui
.clipboard
.copy(text, CopyFormat::Markdown, tui.frame_requester());
self.chat_widget.show_copy_result("last message", result);
}
KeyEventAction::PasteImage => {
// Finish paste before the next key, but never wait on the copy worker's lock.
if tui.clipboard.is_busy() {
self.chat_widget.add_info_message(
"Copy already in progress; try image paste after it finishes".into(),
/*hint*/ None,
);
} else {
self.chat_widget.paste_image();
}
}
}
}
pub(super) fn finish_clipboard(&mut self, tui: &mut tui::Tui) {
let current = tui.is_owned_screen() && self.overlay.is_none();
let Some(completion) = tui.clipboard.poll() else {
return;
};
if let Some(characters) = self.chat_widget.finish_clipboard(completion, current) {
self.transcript_view
.show_copy_feedback(&completion.1, characters);
}
let follow = self
.transcript_view
.finish_copy(&self.transcript_cells, completion, current);
if follow == Some(true) {
if self.backtrack.overlay_preview_active {
self.close_transcript_overlay(tui);
}
self.transcript_view.jump_to_latest();
}
}
}
+6 -3
View File
@@ -41,6 +41,7 @@ impl App {
AppEvent::OpenDaemonMenu
| AppEvent::OpenWarnings
| AppEvent::CopyWarning(_)
| AppEvent::CopySelection { .. }
| AppEvent::ConfirmDaemonUpdate(_)
| AppEvent::RunDaemonUpdate(_)
| AppEvent::InsertHistoryCell(_)
@@ -344,13 +345,14 @@ impl App {
}
AppEvent::OpenWarnings => self.chat_widget.open_warnings(&self.transcript_cells),
AppEvent::CopyWarning(text) => {
let _ = self.chat_widget.copy_transcript_selection(&text);
let result = tui.copy_transcript_selection(&text);
self.chat_widget.show_selection_copy_result(result);
}
AppEvent::OpenTranscriptExportFilePrompt => {
self.chat_widget.show_transcript_export_file_prompt();
}
AppEvent::ExportTranscript { destination } => {
if let Err(error) = self.export_transcript(app_server, destination).await {
if let Err(error) = self.export_transcript(tui, app_server, destination).await {
self.chat_widget
.add_error_message(format!("Export failed: {error}"));
}
@@ -361,7 +363,8 @@ impl App {
}
}
AppEvent::CopySelection { text, label, format } => {
self.chat_widget.copy_selection(text, label, format);
let result = tui.clipboard.copy(text, format, tui.frame_requester());
self.chat_widget.show_copy_result(&label, result);
}
AppEvent::ClearUi { name } => {
if self.reject_pending_permission_root_switch() {
+4 -2
View File
@@ -586,7 +586,8 @@ impl App {
} else if self.should_reject_side_backtrack_esc(key_event) {
self.reject_side_backtrack_esc();
} else {
self.chat_widget.handle_key_event(key_event);
let action = self.chat_widget.handle_key_event(key_event);
self.handle_clipboard_key_action(tui, action);
}
return;
}
@@ -620,7 +621,8 @@ impl App {
self.reset_backtrack_state();
}
}
self.chat_widget.handle_key_event(key_event);
let action = self.chat_widget.handle_key_event(key_event);
self.handle_clipboard_key_action(tui, action);
}
_ => {
self.chat_widget.handle_key_event(key_event);
+8 -14
View File
@@ -461,24 +461,18 @@ impl App {
ViewAction::Copy(text)
| ViewAction::CopyOnSelect(text)
| ViewAction::CopyAndFollow(text) => {
let result = if copy_on_select {
tui.copy_transcript_selection(&text)
} else {
self.transcript_view.copy_selected_text_with(
&self.transcript_cells,
&text,
|text| tui.copy_transcript_selection(text),
)
};
let result = self.transcript_view.copy_selected_text_with(
&self.transcript_cells,
&text,
!copy_on_select,
|text| tui.copy_transcript_selection(text),
);
self.transcript_view
.show_copy_feedback(&result, text.chars().count());
if resume_following
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed))
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
{
if self.backtrack.overlay_preview_active {
self.close_transcript_overlay(tui);
}
self.transcript_view.jump_to_latest();
self.transcript_view.follow_pending_copy();
}
}
ViewAction::OpenLink(url) => self.open_url_in_browser(url),
+3 -1
View File
@@ -31,6 +31,7 @@ use crate::thread_transcript::thread_items_to_transcript_cells;
impl App {
pub(super) async fn export_transcript(
&mut self,
tui: &mut crate::tui::Tui,
app_server: &mut AppServerSession,
destination: TranscriptExportDestination,
) -> Result<(), String> {
@@ -54,7 +55,8 @@ impl App {
let markdown = render_markdown_transcript(&cells)?;
match destination {
TranscriptExportDestination::Clipboard => {
self.chat_widget.copy_transcript_to_clipboard(&markdown);
let result = tui.copy_transcript_selection(&markdown);
self.chat_widget.show_copy_result("conversation", result);
}
TranscriptExportDestination::File(path) => {
let cwd = if self.app_server_target.uses_remote_workspace() {
+1 -1
View File
@@ -442,7 +442,7 @@ pub(crate) enum AppEvent {
destination: TranscriptExportDestination,
},
/// Copy a picker selection while retaining its clipboard lease in the chat widget.
/// Copy text through the session clipboard worker.
CopySelection {
text: Arc<str>,
label: String,
@@ -11,6 +11,14 @@ use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
impl ChatComposer {
pub(in crate::bottom_pane) fn finish_copy(
&mut self,
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
current: bool,
) -> Option<usize> {
self.draft.textarea.finish_copy(completion, current)
}
pub(crate) fn copy_selection(
&mut self,
event: &TuiEvent,
@@ -36,6 +44,9 @@ impl ChatComposer {
let text = &self.draft.textarea.text()[range];
let char_count = text.chars().count();
let result = copy(text);
if let Ok(CopyStatus::Pending(id)) = result {
self.draft.textarea.defer_copy(id);
}
if result == Ok(CopyStatus::Confirmed) {
self.draft.textarea.set_cursor(self.draft.textarea.cursor());
}
@@ -155,4 +155,27 @@ fn right_click_copy_uses_the_refreshed_editor_bounds() {
Some((5, Ok(CopyStatus::Unconfirmed)))
);
assert_eq!(composer.current_text(), "hello world");
let event = TuiEvent::Mouse(MouseEvent { row, ..click });
composer.copy_selection(&event, |_| Ok(CopyStatus::Pending(1)));
// A fresh pointer selection must not be cleared by the older copy.
for (kind, column) in [
(Down(Left), x - 11),
(MouseEventKind::Drag(Left), x - 6),
(Up(Left), x - 6),
] {
mouse(&mut composer, shifted_area, kind, column, row);
}
let selection = composer.draft.textarea.mouse_selection_range();
assert_eq!(
composer.finish_copy(&(1, Ok(CopyStatus::Confirmed)), /*current*/ true),
None
);
assert_eq!(composer.draft.textarea.mouse_selection_range(), selection);
let key = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
composer.copy_selection(&key, |_| Ok(CopyStatus::Pending(2)));
assert_eq!(
composer.finish_copy(&(2, Ok(CopyStatus::Confirmed)), /*current*/ true),
Some(5)
);
assert_eq!(composer.draft.textarea.mouse_selection_range(), None);
}
+9
View File
@@ -1745,6 +1745,15 @@ impl BottomPane {
self.composer.end_mouse_drag();
}
pub(crate) fn finish_composer_copy(
&mut self,
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
visible: bool,
) -> Option<usize> {
let current = visible && !self.has_active_view();
self.composer.finish_copy(completion, current)
}
pub(crate) fn copy_composer_selection(
&mut self,
event: &crate::tui::TuiEvent,
@@ -9,6 +9,7 @@ use ratatui::layout::Position;
#[derive(Debug)]
pub(super) struct MouseSelection {
pending_copy: Option<(u64, Range<usize>)>,
origin: Range<usize>,
unit: SelectionUnit,
dragging: bool,
@@ -16,6 +17,34 @@ pub(super) struct MouseSelection {
}
impl TextArea {
pub(in crate::bottom_pane) fn defer_copy(&mut self, id: u64) {
if let Some(range) = self.mouse_selection_range()
&& let Some(selection) = &mut self.mouse_selection
{
selection.pending_copy = Some((id, range));
}
}
pub(in crate::bottom_pane) fn finish_copy(
&mut self,
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
current: bool,
) -> Option<usize> {
let pending = self.mouse_selection.as_mut()?.pending_copy.as_ref()?;
if pending.0 != completion.0 {
return None;
}
let (_, range) = self.mouse_selection.as_mut()?.pending_copy.take()?;
if !current || self.mouse_selection_range().as_ref() != Some(&range) {
return None;
}
let characters = self.text()[range].chars().count();
if completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed) {
self.set_cursor(self.cursor());
}
Some(characters)
}
pub(in crate::bottom_pane) fn contains_mouse(&self, event: MouseEvent) -> bool {
self.rendered_area
.get()
@@ -89,6 +118,7 @@ impl TextArea {
let clicks =
crate::text_selection::click_count(&mut self.last_click, event.column, event.row);
MouseSelection {
pending_copy: None,
origin: pos..pos,
// Padding remains an insertion target on repeated clicks.
unit: SelectionUnit::from_clicks(if pos == line.end - 1 { 1 } else { clicks }),
+4 -2
View File
@@ -301,10 +301,12 @@ mod pets;
mod session_flow;
mod session_header;
use self::session_header::SessionHeader;
mod clipboard;
mod copy_picker;
mod hook_lifecycle;
mod hooks;
mod interaction;
pub(crate) use interaction::KeyEventAction;
mod skills;
mod slash_dispatch;
mod worktree_picker;
@@ -585,8 +587,8 @@ pub(crate) struct ChatWidget {
// Stream lifecycle controller for proposed plan output.
plan_stream_controller: Option<PlanStreamController>,
pending_stream_consolidations: usize,
/// Holds the platform clipboard lease so copied text remains available while supported.
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
/// Copy feedback is discarded with its originating conversation.
pending_clipboard: Option<clipboard::PendingCopy>,
copy_last_response_binding: Vec<KeyBinding>,
running_commands: HashMap<String, RunningCommand>,
collab_agent_metadata: HashMap<ThreadId, AgentMetadata>,
+64
View File
@@ -0,0 +1,64 @@
//! Handle clipboard feedback and image paste after the app checks worker availability.
//! Pending feedback belongs to this widget, so switching conversations discards it.
use super::*;
use crate::clipboard_copy::worker::CopyResult;
pub(super) enum PendingCopy {
Message(u64, String),
Selection(u64),
}
impl ChatWidget {
pub(crate) fn paste_image(&mut self) {
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
}
pub(super) fn show_clipboard_flash(&mut self, result: &CopyResult) {
let line = match result {
Ok(status) => Line::from(status.message("selection").dim()),
Err(error) => Line::from(format!("Copy failed: {error}").red()),
};
self.bottom_pane
.show_footer_flash(line, Duration::from_secs(/*secs*/ 3));
}
pub(crate) fn finish_clipboard(
&mut self,
completion: &(u64, CopyResult),
composer_visible: bool,
) -> Option<usize> {
let pending_id = match &self.pending_clipboard {
Some(PendingCopy::Message(id, _) | PendingCopy::Selection(id)) => Some(*id),
None => None,
};
if pending_id == Some(completion.0) {
match self.pending_clipboard.take() {
Some(PendingCopy::Message(_, label)) => match &completion.1 {
Ok(status) => self.add_info_message(status.message(&label), /*hint*/ None),
Err(error) => self.add_error_message(format!("Copy failed: {error}")),
},
Some(PendingCopy::Selection(_)) => self.show_clipboard_flash(&completion.1),
None => {}
}
}
self.bottom_pane
.finish_composer_copy(completion, composer_visible)
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ impl ChatWidget {
stream_controller: None,
plan_stream_controller: None,
pending_stream_consolidations: 0,
clipboard_lease: None,
pending_clipboard: None,
copy_last_response_binding,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
+1 -1
View File
@@ -69,7 +69,7 @@ impl ChatWidget {
);
}
if choices.is_empty() {
self.copy_last_agent_markdown();
self.prepare_last_response_copy();
return;
}
+48 -108
View File
@@ -1,8 +1,18 @@
//! Key routing and composer-adjacent UI interaction for `ChatWidget`.
use super::clipboard::PendingCopy;
use super::*;
use crate::bottom_pane::BottomPaneView;
use crate::clipboard_copy::CopyFormat;
use crate::clipboard_copy::CopyStatus;
use crate::clipboard_copy::worker::CopyResult;
/// An input action the app must finish before processing the next key.
#[derive(Debug)]
pub(crate) enum KeyEventAction {
None,
CopyLastResponse(Arc<str>),
PasteImage,
}
impl ChatWidget {
pub(crate) fn end_composer_drag(&mut self) {
@@ -33,12 +43,12 @@ impl ChatWidget {
self.bottom_pane.keymap_contexts()
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventAction {
if self.handle_startup_submission_key(key_event) {
return;
return KeyEventAction::None;
}
if self.handle_question_key(key_event) {
return;
return KeyEventAction::None;
}
if self.bottom_pane.has_active_view()
&& !matches!(
@@ -64,12 +74,12 @@ impl ChatWidget {
if self.bottom_pane.no_modal_or_popup_active() {
self.on_modal_or_popup_closed();
}
return;
return KeyEventAction::None;
}
if self.shortcut_overlay_visible() && key_hint::plain(KeyCode::Esc).is_press(key_event) {
self.bottom_pane.handle_key_event(key_event);
return;
return KeyEventAction::None;
}
if (self.chat_keymap.interrupt_turn.is_pressed(key_event)
@@ -87,14 +97,14 @@ impl ChatWidget {
} else {
self.cancel_image_submission();
}
return;
return KeyEventAction::None;
}
if self.handle_reasoning_shortcut(key_event) || self.handle_permission_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
return;
return KeyEventAction::None;
}
if key_event.kind == KeyEventKind::Press
@@ -103,8 +113,7 @@ impl ChatWidget {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
return self.prepare_last_response_copy();
}
match key_event {
@@ -115,7 +124,7 @@ impl ChatWidget {
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => {
self.on_ctrl_c();
return;
return KeyEventAction::None;
}
KeyEvent {
code: KeyCode::Char(c),
@@ -124,7 +133,7 @@ impl ChatWidget {
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
if self.on_ctrl_d() {
return;
return KeyEventAction::None;
}
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
@@ -138,24 +147,7 @@ impl ChatWidget {
} if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& c.eq_ignore_ascii_case(&'v') =>
{
match paste_image_to_temp_png() {
Ok((path, info)) => {
tracing::debug!(
"pasted image size={}x{} format={}",
info.width,
info.height,
info.encoded_format.label()
);
self.attach_image(path);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
}
return;
return KeyEventAction::PasteImage;
}
other if other.kind == KeyEventKind::Press => {
self.bottom_pane.clear_quit_shortcut_hint();
@@ -178,7 +170,7 @@ impl ChatWidget {
} else {
self.cancel_image_submission();
}
return;
return KeyEventAction::None;
}
const REVIEW_STEER_UNAVAILABLE_MESSAGE: &str = "Steer messages aren't supported during /review. Press Ctrl+C now to cancel the review.";
@@ -192,7 +184,7 @@ impl ChatWidget {
&& !self.should_handle_vim_insert_escape(key_event)
{
self.add_warning_message(REVIEW_STEER_UNAVAILABLE_MESSAGE.to_string());
return;
return KeyEventAction::None;
}
if self.chat_keymap.interrupt_turn.is_pressed(key_event)
@@ -207,11 +199,11 @@ impl ChatWidget {
} else {
self.input_queue.submit_pending_steers_after_interrupt = false;
}
return;
return KeyEventAction::None;
}
if self.handle_plugins_popup_key_event(key_event) {
return;
return KeyEventAction::None;
}
match key_event {
@@ -248,6 +240,7 @@ impl ChatWidget {
self.handle_composer_input_result(input_result, had_modal_or_popup);
}
}
KeyEventAction::None
}
/// Attach a local image to the composer when the active model supports image inputs.
@@ -355,94 +348,41 @@ impl ChatWidget {
false
}
/// Copy the last response as Markdown with HTML for rich-text destinations.
pub(crate) fn copy_last_agent_markdown(&mut self) {
self.copy_last_agent_markdown_with(|text| {
crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::Markdown)
});
}
/// Inner implementation with an injectable clipboard backend for testing.
pub(super) fn copy_last_agent_markdown_with(
&mut self,
copy_fn: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyOutcome, String>,
) {
// The shortcut bypasses composer submission, which normally reveals local feedback.
/// Capture the last response for the app to copy before processing another key.
pub(super) fn prepare_last_response_copy(&mut self) -> KeyEventAction {
self.app_event_tx.send(AppEvent::FollowTranscript);
match self.transcript.last_agent_markdown.clone() {
let action = match self.transcript.last_agent_markdown.as_deref() {
Some(markdown) if !markdown.is_empty() => {
match self.write_clipboard(&markdown, copy_fn) {
Ok(status) => {
self.add_to_history(history_cell::new_info_event(
status.message("last message"),
/*hint*/ None,
));
}
Err(error) => self.add_to_history(history_cell::new_error_event(format!(
"Copy failed: {error}"
))),
}
KeyEventAction::CopyLastResponse(markdown.into())
}
_ => {
self.add_error_message("No agent response to copy".into());
KeyEventAction::None
}
_ => self.add_to_history(history_cell::new_error_event(
"No agent response to copy".into(),
)),
}
self.request_redraw();
}
/// Report a transcript copy without adding history and return its outcome to the viewport.
pub(crate) fn copy_transcript_selection(
&mut self,
text: &str,
) -> Result<crate::clipboard_copy::CopyStatus, String> {
self.copy_transcript_selection_with(text, |text| {
crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::PlainText)
})
}
/// The owned viewport renders its own copy feedback above the composer.
pub(super) fn copy_transcript_selection_with(
&mut self,
text: &str,
copy_fn: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyOutcome, String>,
) -> Result<crate::clipboard_copy::CopyStatus, String> {
let result = self.write_clipboard(text, copy_fn);
let line = match &result {
Ok(status) => Line::from(status.message("selection").dim()),
Err(error) => Line::from(format!("Copy failed: {error}").red()),
};
self.bottom_pane
.show_footer_flash(line, Duration::from_secs(/*secs*/ 3));
result
self.request_redraw();
action
}
pub(crate) fn copy_selection(&mut self, text: Arc<str>, label: String, format: CopyFormat) {
self.copy_selection_with(&text, &label, |text| {
crate::clipboard_copy::copy_to_clipboard(text, format)
});
/// Report a selection copy in the footer without adding history.
pub(crate) fn show_selection_copy_result(&mut self, result: CopyResult) {
if let Ok(CopyStatus::Pending(id)) = result {
self.pending_clipboard = Some(PendingCopy::Selection(id));
}
self.show_clipboard_flash(&result);
}
pub(super) fn copy_selection_with(
&mut self,
text: &str,
label: &str,
copy_fn: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyOutcome, String>,
) {
match self.write_clipboard(text, copy_fn) {
pub(crate) fn show_copy_result(&mut self, label: &str, result: CopyResult) {
if let Ok(CopyStatus::Pending(id)) = result {
self.pending_clipboard = Some(PendingCopy::Message(id, label.into()));
}
match result {
Ok(status) => self.add_info_message(status.message(label), /*hint*/ None),
Err(error) => self.add_error_message(format!("Copy failed: {error}")),
}
self.request_redraw();
}
fn write_clipboard(
&mut self,
text: &str,
copy_fn: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyOutcome, String>,
) -> Result<crate::clipboard_copy::CopyStatus, String> {
Ok(copy_fn(text)?.store(&mut self.clipboard_lease))
}
#[cfg(test)]
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
self.transcript.last_agent_markdown.as_deref()
@@ -1831,10 +1831,7 @@ async fn slash_copy_picker_copies_status_fields_and_preserves_source_after_copyi
next_copy_selection(&mut rx),
(value.to_string(), label.to_string())
);
chat.copy_selection_with(value, label, |text| {
assert_eq!(text, value);
Ok(crate::clipboard_copy::CopyOutcome::Copied(None))
});
chat.show_copy_result(label, Ok(crate::clipboard_copy::CopyStatus::Confirmed));
drain_insert_history(&mut rx);
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
@@ -2353,9 +2350,15 @@ async fn slash_keymap_invalid_args_show_usage() {
#[tokio::test]
async fn copy_shortcut_can_be_remapped() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
for modifiers in [KeyModifiers::CONTROL, KeyModifiers::ALT] {
assert_matches!(
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), modifiers)),
crate::chatwidget::KeyEventAction::PasteImage
);
}
let mut keymap_config = chat.config_ref().tui_keymap.clone();
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-x".to_string()),
codex_config::types::KeybindingSpec("alt-v".to_string()),
));
let runtime_keymap =
crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap");
@@ -2367,7 +2370,10 @@ async fn copy_shortcut_can_be_remapped() {
"old copy shortcut should no longer copy"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
assert_matches!(
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
crate::chatwidget::KeyEventAction::None
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
@@ -2378,79 +2384,35 @@ async fn copy_shortcut_can_be_remapped() {
}
#[tokio::test]
async fn slash_copy_preserves_native_lease_after_terminal_copy_or_failure() {
async fn copy_shortcut_submits_markdown_and_reports_completion_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some("copy me".to_string());
chat.copy_last_agent_markdown_with(|markdown| {
assert_eq!(markdown, "copy me");
Ok(crate::clipboard_copy::CopyOutcome::Copied(Some(
crate::clipboard_copy::ClipboardLease::test(),
)))
});
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
assert!(chat.clipboard_lease.is_some());
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one success message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Copied last message to clipboard"),
"expected success message, got {rendered:?}"
assert_matches!(
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)),
crate::chatwidget::KeyEventAction::CopyLastResponse(text) if &*text == "copy me"
);
chat.copy_last_agent_markdown_with(|_| Ok(crate::clipboard_copy::CopyOutcome::Requested));
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
insta::assert_snapshot!("unconfirmed_copy", rendered);
chat.copy_last_agent_markdown_with(|markdown| {
assert_eq!(markdown, "copy me");
Err("blocked".into())
});
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
assert!(chat.clipboard_lease.is_some());
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one failure message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Copy failed: blocked"),
"expected failure message, got {rendered:?}"
assert!(rx.try_recv().is_err());
chat.show_copy_result(
"last message",
Ok(crate::clipboard_copy::CopyStatus::Pending(1)),
);
let pending = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
insta::assert_snapshot!("pending_copy", pending);
let completion = (1, Ok(crate::clipboard_copy::CopyStatus::Confirmed));
chat.finish_clipboard(&completion, /*composer_visible*/ true);
let result = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(result.contains("Copied last message to clipboard"));
chat.finish_clipboard(&completion, /*composer_visible*/ true);
assert!(drain_insert_history(&mut rx).is_empty());
chat.show_copy_result(
"last message",
Ok(crate::clipboard_copy::CopyStatus::Unconfirmed),
);
insta::assert_snapshot!(
"unconfirmed_copy",
lines_to_single_string(&drain_insert_history(&mut rx)[0])
);
chat.copy_selection_with("print('ok')\n", "python code", |content| {
assert_eq!(content, "print('ok')\n");
Ok(crate::clipboard_copy::CopyOutcome::Copied(Some(
crate::clipboard_copy::ClipboardLease::test(),
)))
});
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(rendered.contains("Copied python code to clipboard"));
chat.copy_selection_with("terminal copy", "code", |_| {
Ok(crate::clipboard_copy::CopyOutcome::Copied(None))
});
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(rendered.contains("Copied code to clipboard"));
chat.copy_selection_with("print('blocked')\n", "python code", |content| {
assert_eq!(content, "print('blocked')\n");
Err("blocked selection".to_string())
});
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(rendered.contains("Copy failed: blocked selection"));
chat.copy_selection_with("print('ok')\n", "python code", |_| {
Ok(crate::clipboard_copy::CopyOutcome::Requested)
});
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(rendered.contains("Copy unconfirmed; /export saves chat"));
}
#[tokio::test]
@@ -3637,27 +3599,16 @@ async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interru
let (mut chat, mut events, mut operations) =
make_chatwidget_manual(/*model_override*/ None).await;
let _ = drain_insert_history(&mut events);
let result = chat.copy_transcript_selection_with("selected café\nsecond line", |text| {
assert_eq!(text, "selected café\nsecond line");
Ok(crate::clipboard_copy::CopyOutcome::Copied(Some(
crate::clipboard_copy::ClipboardLease::test(),
)))
});
assert_eq!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed));
chat.show_selection_copy_result(Ok(crate::clipboard_copy::CopyStatus::Confirmed));
assert_eq!(drain_insert_history(&mut events).len(), 0);
assert!(operations.try_recv().is_err());
assert!(chat.clipboard_lease.is_some());
insta::assert_snapshot!(
"transcript_copy_success",
render_bottom_popup(&chat, /*width*/ 80)
);
let result = chat.copy_transcript_selection_with("selected café", |_text| {
Ok(crate::clipboard_copy::CopyOutcome::Requested)
});
assert_eq!(result, Ok(crate::clipboard_copy::CopyStatus::Unconfirmed));
chat.show_selection_copy_result(Ok(crate::clipboard_copy::CopyStatus::Unconfirmed));
assert_eq!(drain_insert_history(&mut events).len(), 0);
assert!(operations.try_recv().is_err());
assert!(chat.clipboard_lease.is_some());
insta::assert_snapshot!(
"transcript_copy_unconfirmed",
render_bottom_popup(&chat, /*width*/ 80)
@@ -3669,13 +3620,9 @@ async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interru
chat.open_warnings(&[Arc::new(history_cell::new_warning_event(
"selected café".into(),
))]);
let result = chat.copy_transcript_selection_with("selected café", |_text| {
Err("clipboard unavailable".to_string())
});
assert_eq!(result, Err("clipboard unavailable".to_string()));
chat.show_selection_copy_result(Err("clipboard unavailable".to_string()));
assert_eq!(drain_insert_history(&mut events).len(), 0);
assert!(operations.try_recv().is_err());
assert!(chat.clipboard_lease.is_some());
insta::assert_snapshot!(
"transcript_copy_failure",
render_bottom_popup(&chat, /*width*/ 80)
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
assertion_line: 2392
expression: pending
---
• Copying last message…
@@ -5,19 +5,6 @@ use crate::app_event::TranscriptExportDestination;
use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap;
impl ChatWidget {
pub(crate) fn copy_transcript_to_clipboard(&mut self, markdown: &str) {
match crate::clipboard_copy::copy_to_clipboard(
markdown,
crate::clipboard_copy::CopyFormat::PlainText,
) {
Ok(outcome) => {
let status = outcome.store(&mut self.clipboard_lease);
self.add_info_message(status.message("conversation"), /*hint*/ None);
}
Err(error) => self.add_error_message(format!("Copy failed: {error}")),
}
}
pub(super) fn show_transcript_export_popup(&mut self) {
self.show_selection_view(SelectionViewParams {
header: Box::new(
+35 -87
View File
@@ -10,7 +10,7 @@
//!
//! On Linux, X11 and some Wayland compositors require the process that wrote the
//! clipboard to keep its handle open. `ClipboardLease` wraps the `arboard::Clipboard`
//! so callers can store it for the lifetime of the TUI. On other platforms the lease
//! so the copy worker can retain it for the lifetime of the TUI. On other platforms the lease
//! is always `None`.
//!
//! Empty selections fail without modifying a clipboard.
@@ -20,6 +20,7 @@
//! Image paste lives in `clipboard_paste`.
mod tmux;
pub(crate) mod worker;
use base64::Engine;
use std::io::Write;
@@ -28,10 +29,6 @@ use tmux::copy as tmux_clipboard_copy;
/// Maximum raw bytes sent through tmux or directly encoded into OSC 52.
/// Large payloads are rejected before encoding to avoid overwhelming the terminal.
const OSC52_MAX_RAW_BYTES: usize = 100_000;
#[cfg(target_os = "macos")]
static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock<std::sync::Mutex<()>> =
std::sync::OnceLock::new();
/// Whether copied text should also have a rendered HTML representation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CopyFormat {
@@ -60,11 +57,13 @@ impl CopyOutcome {
}
}
/// Whether a clipboard backend confirmed the write or only sent a request.
/// Submission progress or the delivery status reported by a clipboard backend.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CopyStatus {
Confirmed,
Unconfirmed,
Pending(u64),
Busy,
}
impl CopyStatus {
@@ -72,6 +71,8 @@ impl CopyStatus {
match self {
Self::Confirmed => format!("Copied {label} to clipboard"),
Self::Unconfirmed => "Copy unconfirmed; /export saves chat".to_string(),
Self::Pending(_) => format!("Copying {label}…"),
Self::Busy => "Copy already in progress".to_string(),
}
}
}
@@ -87,7 +88,24 @@ impl CopyStatus {
/// existing native lease unless the backend supplies a replacement.
///
/// OSC 52 is supported by kitty, WezTerm, iTerm2, Ghostty, and others.
pub(crate) fn copy_to_clipboard(text: &str, format: CopyFormat) -> Result<CopyOutcome, String> {
fn copy_to_clipboard(
text: &str,
format: CopyFormat,
begin_delivery: impl Fn() -> Result<(), String>,
osc52: impl Fn(&str) -> Result<(), String>,
) -> Result<CopyOutcome, String> {
#[cfg(not(target_os = "android"))]
let native_copy = {
let clipboard = arboard::Clipboard::new();
move |text: &str, html: Option<&str>| arboard_copy(clipboard, text, html)
};
#[cfg(target_os = "android")]
let native_copy = |_text: &str, _html: Option<&str>| {
Err("native clipboard unavailable on Android".to_string())
};
// Decide before propagating setup success or failure: either result could otherwise
// start a native write or fallback long after the user abandoned this request.
begin_delivery()?;
copy_to_clipboard_with(
text,
format,
@@ -97,8 +115,8 @@ pub(crate) fn copy_to_clipboard(text: &str, format: CopyFormat) -> Result<CopyOu
tmux_session: is_tmux_session(),
},
tmux_clipboard_copy,
osc52_copy,
arboard_copy,
osc52,
native_copy,
wsl_clipboard_copy,
)
}
@@ -148,7 +166,7 @@ fn copy_to_clipboard_with(
environment: CopyEnvironment,
tmux_copy_fn: impl Fn(&str) -> Result<(), String>,
osc52_copy_fn: impl Fn(&str) -> Result<(), String>,
arboard_copy_fn: impl Fn(&str, Option<&str>) -> Result<Option<ClipboardLease>, String>,
arboard_copy_fn: impl FnOnce(&str, Option<&str>) -> Result<Option<ClipboardLease>, String>,
wsl_copy_fn: impl Fn(&str) -> Result<(), String>,
) -> Result<CopyOutcome, String> {
if text.is_empty() {
@@ -223,22 +241,14 @@ fn is_wsl_session() -> bool {
false
}
/// Run arboard with stderr suppressed.
///
/// On macOS, `arboard::Clipboard::new()` initializes `NSPasteboard` which
/// triggers `os_log` / `NSLog` output on stderr. Because the TUI owns the
/// terminal, that stray output corrupts the display. We temporarily redirect
/// fd 2 to `/dev/null` around the call to keep the screen clean.
/// Write to the native clipboard. The TUI owns any process-wide stderr redirection.
#[cfg(not(target_os = "android"))]
fn arboard_copy(text: &str, html: Option<&str>) -> Result<Option<ClipboardLease>, String> {
#[cfg(target_os = "macos")]
let _stderr_lock = STDERR_SUPPRESSION_MUTEX
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.map_err(|_| "stderr suppression lock poisoned".to_string())?;
let _guard = SuppressStderr::new();
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
fn arboard_copy(
clipboard: Result<arboard::Clipboard, arboard::Error>,
text: &str,
html: Option<&str>,
) -> Result<Option<ClipboardLease>, String> {
let mut clipboard = clipboard.map_err(|e| format!("clipboard unavailable: {e}"))?;
match html {
Some(html) => clipboard
.set_html(html, Some(text))
@@ -257,11 +267,6 @@ fn arboard_copy(text: &str, html: Option<&str>) -> Result<Option<ClipboardLease>
}
}
#[cfg(target_os = "android")]
fn arboard_copy(_text: &str, _html: Option<&str>) -> Result<Option<ClipboardLease>, String> {
Err("native clipboard unavailable on Android".to_string())
}
/// Copy text into the Windows clipboard from a WSL process.
#[cfg(target_os = "linux")]
fn wsl_clipboard_copy(text: &str) -> Result<(), String> {
@@ -318,63 +323,6 @@ fn wsl_clipboard_copy(_text: &str) -> Result<(), String> {
Err("WSL clipboard fallback unavailable on this platform".to_string())
}
/// RAII guard that redirects stderr (fd 2) to `/dev/null` on creation and
/// restores the original fd on drop.
#[cfg(target_os = "macos")]
struct SuppressStderr {
saved_fd: Option<libc::c_int>,
}
#[cfg(target_os = "macos")]
impl SuppressStderr {
fn new() -> Self {
unsafe {
// Save the current stderr fd.
let saved = libc::dup(2);
if saved < 0 {
return Self { saved_fd: None };
}
// Open /dev/null and point fd 2 at it.
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
if devnull < 0 {
libc::close(saved);
return Self { saved_fd: None };
}
if libc::dup2(devnull, 2) < 0 {
libc::close(saved);
libc::close(devnull);
return Self { saved_fd: None };
}
libc::close(devnull);
Self {
saved_fd: Some(saved),
}
}
}
}
#[cfg(target_os = "macos")]
impl Drop for SuppressStderr {
fn drop(&mut self) {
if let Some(saved) = self.saved_fd {
unsafe {
libc::dup2(saved, 2);
libc::close(saved);
}
}
}
}
#[cfg(not(target_os = "macos"))]
struct SuppressStderr;
#[cfg(not(target_os = "macos"))]
impl SuppressStderr {
fn new() -> Self {
Self
}
}
/// Write text to the clipboard via the OSC 52 terminal escape sequence.
fn osc52_copy(text: &str) -> Result<(), String> {
let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
@@ -0,0 +1,6 @@
---
source: tui/src/clipboard_copy/worker_tests.rs
assertion_line: 82
expression: "feedback.join(\"\\n\")"
---
• Copy already in progress; try image paste after it finishes
+250
View File
@@ -0,0 +1,250 @@
//! One session-lived worker owns blocking clipboard operations and native leases.
//! Setup has a five-second budget, but abandoning it cannot interrupt an OS call.
//! There is no backlog: the worker stays busy until that call returns. Once delivery
//! starts it must finish. Terminal escape sequences are emitted by the UI.
use super::ClipboardLease;
use super::CopyFormat;
use super::CopyStatus;
use crate::tui::FrameRequester;
use std::cell::Cell;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::mpsc;
use std::time::Duration;
use std::time::Instant;
const SETUP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
const SETUP_TIMEOUT_MESSAGE: &str = "clipboard setup timed out; copy abandoned";
enum Setup {
Pending(Instant),
Delivering,
Abandoned,
}
impl Setup {
fn timed_out(&mut self, now: Instant) -> bool {
if matches!(self, Self::Pending(deadline) if now >= *deadline) {
*self = Self::Abandoned;
}
matches!(self, Self::Abandoned)
}
}
/// The UI deadline and worker delivery decision share one short, non-I/O critical section.
struct CopySetup {
phase: Mutex<Setup>,
frames: FrameRequester,
}
impl CopySetup {
fn begin_delivery(&self) -> Result<(), String> {
let mut setup = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if setup.timed_out(Instant::now()) {
return Err(SETUP_TIMEOUT_MESSAGE.into());
}
*setup = Setup::Delivering;
Ok(())
}
fn timed_out(&self) -> bool {
let mut setup = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let timed_out = setup.timed_out(Instant::now());
if let Setup::Pending(deadline) = *setup {
// Earlier draws consume scheduled frames, so re-arm after every UI poll.
self.frames
.schedule_frame_in(deadline.saturating_duration_since(Instant::now()));
}
timed_out
}
}
pub(crate) type CopyResult = Result<CopyStatus, String>;
struct Request {
text: Arc<str>,
format: CopyFormat,
setup: Arc<CopySetup>,
}
struct Response {
result: CopyResult,
terminal_text: Option<String>,
}
#[derive(Default)]
pub(crate) struct ClipboardWorker {
requests: Option<mpsc::Sender<Request>>,
responses: Option<mpsc::Receiver<Response>>,
pending: Option<(u64, Arc<CopySetup>)>,
next_id: u64,
completed: Option<(u64, CopyResult)>,
}
impl ClipboardWorker {
pub(crate) fn is_busy(&self) -> bool {
self.pending.is_some()
}
pub(crate) fn copy(
&mut self,
text: Arc<str>,
format: CopyFormat,
frames: FrameRequester,
) -> CopyResult {
if self.is_busy() {
return Ok(CopyStatus::Busy);
}
if text.is_empty() {
return Err("nothing to copy: the selected content is empty".into());
}
if self.requests.is_none() {
self.start(frames.clone(), |text, format, setup| {
let terminal_text = Cell::new(/*value*/ None);
let result = super::copy_to_clipboard(
text,
format,
|| setup.begin_delivery(),
|text| {
// Validate the limit before accepting a deferred terminal send.
super::osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
terminal_text.set(Some(text.to_owned()));
Ok(())
},
);
(result, terminal_text.into_inner())
})?;
}
self.next_id += 1;
let id = self.next_id;
let setup = Arc::new(CopySetup {
phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)),
frames: frames.clone(),
});
self.requests
.as_ref()
.ok_or("clipboard worker stopped")?
.send(Request {
text,
format,
setup: Arc::clone(&setup),
})
.map_err(|_| "clipboard worker stopped".to_string())?;
self.pending = Some((id, setup));
// Retain the last completion while a newer request runs so a temporarily hidden
// view can still finish its feedback. Consumers already match request IDs.
frames.schedule_frame_in(SETUP_TIMEOUT);
Ok(CopyStatus::Pending(id))
}
fn start(
&mut self,
frames: FrameRequester,
mut copy: impl FnMut(
&str,
CopyFormat,
&CopySetup,
) -> (Result<super::CopyOutcome, String>, Option<String>)
+ Send
+ 'static,
) -> Result<(), String> {
let (requests, incoming) = mpsc::channel::<Request>();
let (outgoing, responses) = mpsc::channel();
// Native calls can wait indefinitely; shutdown waits only for a bounded handoff.
std::thread::Builder::new()
.name("clipboard-copy".into())
.spawn(move || {
let mut lease: Option<ClipboardLease> = None;
while let Ok(request) = incoming.recv() {
let (outcome, terminal_text) =
copy(&request.text, request.format, &request.setup);
let result = outcome.map(|outcome| outcome.store(&mut lease));
if outgoing
.send(Response {
result,
terminal_text,
})
.is_err()
{
break;
}
frames.schedule_frame();
}
// Finish Linux clipboard-manager handoff before disconnecting the response channel.
#[cfg(target_os = "linux")]
drop(lease);
drop(outgoing);
})
.map_err(|error| format!("could not start clipboard worker: {error}"))?;
self.requests = Some(requests);
self.responses = Some(responses);
Ok(())
}
/// Poll only while the UI owns the terminal. Retain one result for its original consumer.
pub(crate) fn poll(&mut self) -> Option<&(u64, CopyResult)> {
if let Some((id, setup)) = &self.pending {
let id = *id;
if !matches!(self.completed, Some((completed_id, _)) if completed_id == id)
&& setup.timed_out()
{
self.completed = Some((id, Err(SETUP_TIMEOUT_MESSAGE.into())));
}
match self.responses.as_ref()?.try_recv() {
Ok(response) => {
// A timeout completes the request, not the blocked worker. Drain its
// eventual response without replacing failure or emitting terminal output.
if matches!(self.completed, Some((completed_id, _)) if completed_id == id) {
self.pending = None;
return self.completed.as_ref();
}
let mut result = response.result;
if let Some(text) = response.terminal_text
&& let Err(error) = super::osc52_copy(&text)
&& result != Ok(CopyStatus::Confirmed)
{
result = Err(error);
}
self.completed = Some((id, result));
self.pending = None;
}
Err(mpsc::TryRecvError::Disconnected) => {
if !matches!(self.completed, Some((completed_id, _)) if completed_id == id) {
self.completed = Some((id, Err("clipboard worker stopped".into())));
}
self.pending = None;
}
Err(mpsc::TryRecvError::Empty) => {}
}
}
self.completed.as_ref()
}
}
impl Drop for ClipboardWorker {
fn drop(&mut self) {
self.requests.take();
// A confirmed Linux copy needs time to hand ownership to the clipboard manager.
// Keep this bounded: even dropping a native clipboard can stall on an X11 server.
if let Some(responses) = &self.responses {
let deadline = Instant::now() + Duration::from_millis(/*millis*/ 250);
// A queued result does not mean the lease has finished handing off. Only
// channel disconnection follows that cleanup, including unpolled copies.
while responses
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.is_ok()
{}
}
}
}
#[cfg(test)]
#[path = "worker_tests.rs"]
mod tests;
@@ -0,0 +1,270 @@
//! Blocked backends leave UI work and teardown independent of clipboard progress.
use super::*;
use crate::clipboard_copy::CopyOutcome;
use crate::history_cell::PlainHistoryCell;
use crate::keymap::RuntimeKeymap;
use crate::pager_overlay::Overlay;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use std::time::Duration;
#[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();
let (draws, mut draw_rx) = tokio::sync::broadcast::channel(/*capacity*/ 1);
let frames = FrameRequester::new(draws);
let (started, start_rx) = mpsc::channel();
let (release, released) = mpsc::channel();
tui.clipboard
.start(frames.clone(), move |text, format, setup| {
setup.begin_delivery().unwrap();
started.send((text.to_owned(), format)).unwrap();
released.recv().unwrap();
(Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))), None)
})
.unwrap();
let mut app = Box::new(crate::app::test_support::make_test_app().await);
let (mut chat, tx, mut events, _ops) =
crate::chatwidget::tests::make_chatwidget_manual_with_sender().await;
chat.handle_server_notification(
codex_app_server_protocol::ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
thread_id: String::new(),
turn_id: "turn-1".into(),
completed_at_ms: 0,
item: codex_app_server_protocol::ThreadItem::AgentMessage {
id: "msg-1".into(),
text: "café\nsecond line".into(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
},
),
/*replay_kind*/ None,
);
app.chat_widget = chat;
app.app_event_tx = tx;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config)
.await
.unwrap();
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)),
)
.await
.unwrap();
// Copy must start without draining AppEvent, before a subsequent paste key can arrive.
assert!(tui.clipboard.is_busy());
while events.try_recv().is_ok() {}
assert_eq!(
start_rx
.recv_timeout(Duration::from_secs(/*secs*/ 5))
.unwrap(),
("café\nsecond line".into(), CopyFormat::Markdown)
);
for _ in 0..100 {
assert_eq!(tui.copy_transcript_selection("newer"), Ok(CopyStatus::Busy));
}
assert!(tui.clipboard.poll().is_none());
let mut overlay = Overlay::new_transcript(
vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))],
RuntimeKeymap::defaults().pager,
/*copy_on_select*/ false,
);
overlay
.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
)
.unwrap();
assert!(overlay.is_done());
drop(overlay);
assert!(tui.clipboard.is_busy());
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
)
.await
.unwrap();
let mut feedback = Vec::new();
while let Ok(event) = events.try_recv() {
if let crate::app_event::AppEvent::InsertHistoryCell(cell) = event {
feedback.extend(
cell.display_lines(/*width*/ 80)
.iter()
.map(ToString::to_string),
);
}
}
insta::assert_snapshot!("image_paste_while_copy_is_busy", feedback.join("\n"));
release.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(/*secs*/ 5), draw_rx.recv())
.await
.unwrap()
.unwrap();
assert_eq!(tui.clipboard.poll(), Some(&(1, Ok(CopyStatus::Confirmed))));
assert!(!tui.clipboard.is_busy());
assert!(start_rx.try_recv().is_err());
}
#[test]
fn dropping_worker_does_not_wait_for_blocked_backend() {
let mut worker = ClipboardWorker::default();
let (started, start_rx) = mpsc::channel();
let (release, released) = mpsc::channel();
let (finished, finish_rx) = mpsc::channel();
worker
.start(FrameRequester::test_dummy(), move |_, _, _| {
started.send(()).unwrap();
released.recv().unwrap();
finished.send(()).unwrap();
(Err("unavailable".into()), None)
})
.unwrap();
worker
.copy(
"text".into(),
CopyFormat::PlainText,
FrameRequester::test_dummy(),
)
.unwrap();
start_rx
.recv_timeout(Duration::from_secs(/*secs*/ 5))
.unwrap();
drop(worker);
release.send(()).unwrap();
finish_rx
.recv_timeout(Duration::from_secs(/*secs*/ 5))
.unwrap();
}
#[tokio::test]
async fn expired_setup_discards_delivery_and_keeps_worker_busy_until_it_returns() {
// Expiry must also be enforced by the worker when the UI has not polled yet.
for poll_before_release in [true, false] {
let mut worker = ClipboardWorker::default();
let (frames, mut scheduled) = FrameRequester::test_channel();
let (started, start_rx) = mpsc::channel();
let (release, released) = mpsc::channel();
let (writes, write_rx) = mpsc::channel();
worker
.start(frames.clone(), move |text, _, setup| {
started.send(()).unwrap();
released.recv().unwrap();
let outcome = setup.begin_delivery().map(|()| {
writes.send(text.to_owned()).unwrap();
CopyOutcome::Copied(None)
});
(outcome, None)
})
.unwrap();
worker
.copy("abandoned".into(), CopyFormat::PlainText, frames.clone())
.unwrap();
start_rx.recv_timeout(SETUP_TIMEOUT).unwrap();
scheduled.try_recv().unwrap();
assert!(worker.poll().is_none());
// Every poll re-arms the setup deadline after earlier UI draws consume it.
assert!(scheduled.try_recv().is_ok());
let setup = &worker.pending.as_ref().unwrap().1;
*setup.phase.lock().unwrap() = Setup::Pending(Instant::now());
let failure = (1, Err(SETUP_TIMEOUT_MESSAGE.into()));
if poll_before_release {
assert_eq!(worker.poll(), Some(&failure));
}
assert!(worker.is_busy());
assert_eq!(
worker.copy("retry".into(), CopyFormat::PlainText, frames.clone()),
Ok(CopyStatus::Busy)
);
release.send(()).unwrap();
tokio::time::timeout(SETUP_TIMEOUT, async {
while worker.is_busy() {
worker.poll();
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(worker.poll(), Some(&failure));
assert!(write_rx.try_recv().is_err());
assert!(start_rx.try_recv().is_err());
worker
.copy("fresh".into(), CopyFormat::PlainText, frames)
.unwrap();
start_rx.recv_timeout(SETUP_TIMEOUT).unwrap();
assert_eq!(worker.poll(), Some(&failure));
release.send(()).unwrap();
assert_eq!(write_rx.recv_timeout(SETUP_TIMEOUT).unwrap(), "fresh");
tokio::time::timeout(SETUP_TIMEOUT, async {
while worker.is_busy() {
worker.poll();
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(worker.poll(), Some(&(2, Ok(CopyStatus::Confirmed))));
}
}
#[test]
fn drop_waits_for_response_channel_disconnect() {
let (requests, _incoming) = mpsc::channel();
let (outgoing, responses) = mpsc::sync_channel(/*bound*/ 0);
let mut worker = ClipboardWorker {
requests: Some(requests),
responses: Some(responses),
pending: None,
next_id: 0,
completed: None,
};
worker
.copy(
"text".into(),
CopyFormat::PlainText,
FrameRequester::test_dummy(),
)
.unwrap();
let cleanup = std::thread::spawn(move || {
// The second rendezvous probes cleanup after receiving a result: shutdown
// must wait for disconnection, not mistake the first result for handoff.
for _ in 0..2 {
outgoing
.send(Response {
result: Ok(CopyStatus::Confirmed),
terminal_text: None,
})
.unwrap();
}
});
drop(worker);
cleanup.join().unwrap();
}
#[test]
fn delivery_claim_cannot_be_abandoned() {
let setup = CopySetup {
phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)),
frames: FrameRequester::test_dummy(),
};
assert_eq!(setup.begin_delivery(), Ok(()));
assert!(
!setup
.phase
.lock()
.unwrap()
.timed_out(Instant::now() + SETUP_TIMEOUT)
);
// Backend fallback checks cannot revoke a delivery that already began.
assert_eq!(setup.begin_delivery(), Ok(()));
}
+22 -11
View File
@@ -170,6 +170,20 @@ impl TranscriptOverlay {
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let Some(completion) = tui.clipboard.poll()
&& let Some(follow) =
self.view
.finish_copy(&self.cells, completion, /*current*/ true)
{
self.notice = Some(match &completion.1 {
Ok(status) => status.message("selection"),
Err(error) => format!("Copy failed: {error}"),
});
if follow {
self.view.jump_to_latest();
self.is_done = self.browsing_footer.is_some();
}
}
if matches!(event, TuiEvent::Resume) {
self.view.end_drag();
}
@@ -432,19 +446,16 @@ impl TranscriptOverlay {
ViewAction::Copy(text)
| ViewAction::CopyOnSelect(text)
| ViewAction::CopyAndFollow(text) => {
let result = if copy_on_select {
tui.copy_transcript_selection(&text)
} else {
self.view
.copy_selected_text_with(&self.cells, &text, |text| {
tui.copy_transcript_selection(text)
})
};
let result = self.view.copy_selected_text_with(
&self.cells,
&text,
!copy_on_select,
|text| tui.copy_transcript_selection(text),
);
if resume_following
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed))
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
{
self.view.jump_to_latest();
self.is_done = self.browsing_footer.is_some();
self.view.follow_pending_copy();
}
self.notice = Some(match result {
Ok(status) => status.message("selection"),
+1
View File
@@ -586,6 +586,7 @@ async fn run_session_picker_with_loader(
loop {
tokio::select! {
Some(ev) = tui_events.next() => {
alt.tui.clipboard.poll();
let screen_size = alt.tui.screen_size_for_event(&ev)?;
let ev = if let TuiEvent::Key(key) = ev {
let Some(key) = state.route_key_chord(key) else {
@@ -10,9 +10,9 @@ use std::time::Duration;
use std::time::Instant;
pub(super) struct CopyFeedback {
result: Result<CopyStatus, ()>,
characters: usize,
expires_at: Instant,
pub(super) result: Result<CopyStatus, ()>,
pub(super) characters: usize,
expires_at: Option<Instant>,
}
impl TranscriptView {
@@ -25,7 +25,7 @@ impl TranscriptView {
if let Some(feedback) = self
.copy_feedback
.as_ref()
.filter(|feedback| feedback.expires_at > now)
.filter(|feedback| feedback.expires_at.is_none_or(|expiry| expiry > now))
{
return feedback.line(width.saturating_sub(/*rhs*/ 1)).width() > 0;
}
@@ -51,10 +51,19 @@ impl TranscriptView {
result: &Result<CopyStatus, String>,
characters: usize,
) {
if result == &Ok(CopyStatus::Busy)
&& self
.copy_feedback
.as_ref()
.is_some_and(|feedback| matches!(feedback.result, Ok(CopyStatus::Pending(_))))
{
return;
}
self.copy_feedback = Some(CopyFeedback {
result: result.as_ref().copied().map_err(|_| ()),
characters,
expires_at: Instant::now() + Duration::from_secs(/*secs*/ 5),
expires_at: (!matches!(result, Ok(CopyStatus::Pending(_))))
.then(|| Instant::now() + Duration::from_secs(/*secs*/ 5)),
});
}
@@ -69,7 +78,7 @@ impl TranscriptView {
if self
.copy_feedback
.as_ref()
.is_some_and(|feedback| feedback.expires_at <= now)
.is_some_and(|feedback| feedback.expires_at.is_some_and(|expiry| expiry <= now))
{
self.copy_feedback = None;
}
@@ -79,7 +88,9 @@ impl TranscriptView {
};
if let Some(feedback) = &self.copy_feedback {
let line = feedback.line(area.width.saturating_sub(/*rhs*/ 1));
let delay = feedback.expires_at.saturating_duration_since(now);
let delay = feedback
.expires_at
.map(|expiry| expiry.saturating_duration_since(now));
let width = line.width().min(usize::from(area.width)) as u16;
let target = Rect::new(
area.right().saturating_sub(width + 1).max(area.x),
@@ -89,7 +100,7 @@ impl TranscriptView {
);
line.render(target, buffer);
self.render_follow_control(/*area*/ None, buffer);
return Some(delay);
return delay;
}
self.render_follow_control(Some(area), buffer);
if let Some(hint) = self.fitting_composer_hint(area.width, hint) {
@@ -112,6 +123,16 @@ impl CopyFeedback {
format!("Copied {characters} chars"),
"Copied".into(),
],
Ok(CopyStatus::Pending(_)) => [
format!("Copying {characters} chars…"),
"Copying…".into(),
"Copying…".into(),
],
Ok(CopyStatus::Busy) => [
"Copy already in progress".into(),
"Copy in progress".into(),
"Copy busy".into(),
],
Ok(CopyStatus::Unconfirmed) => [
"Copy sent to terminal · paste to verify".into(),
"Copy sent · verify paste".into(),
@@ -10,10 +10,83 @@ use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
#[test]
fn pending_copy_feedback_survives_expiry_and_selection_replacement() {
let cells = vec![cell("selected text")];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 60, /*height*/ 3);
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
let selected = view.selected_text(&cells).unwrap();
let pending = view.copy_selected_text_with(
&cells,
&selected,
/*clear_selection*/ true,
|_| Ok(CopyStatus::Pending(1)),
);
view.show_copy_feedback(&pending, selected.chars().count());
view.show_copy_feedback(&Ok(CopyStatus::Busy), /*characters*/ 0);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 1,
);
let mut buffer = Buffer::empty(area);
let later = Instant::now() + Duration::from_secs(/*secs*/ 10);
assert!(view.composer_gap_has_content(area.width, /*hint*/ None, later));
assert_eq!(
view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, later),
None
);
let pending_frame = text(&buffer);
assert_eq!(
view.finish_copy(
&cells,
&(1, Err("setup timed out".into())),
/*current*/ true
),
Some(false)
);
assert_eq!(view.selected_text(&cells), Some(selected));
buffer.reset();
view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now());
insta::assert_snapshot!(format!(
"Pending after ten seconds\n{pending_frame}\nSetup failed\n{}",
text(&buffer)
));
// Feedback still completes if the selection owner disappears, including composer copies.
view.show_copy_feedback(&Ok(CopyStatus::Pending(2)), /*characters*/ 3);
view.end_selection(&cells);
assert_eq!(
view.finish_copy(
&cells,
&(2, Ok(CopyStatus::Confirmed)),
/*current*/ false
),
None
);
assert_eq!(
view.copy_feedback.as_ref().unwrap().result,
Ok(CopyStatus::Confirmed)
);
view.show_copy_feedback(&Ok(CopyStatus::Pending(3)), /*characters*/ 4);
view.finish_copy(&cells, &(2, Err("stale".into())), /*current*/ true);
assert_eq!(
view.copy_feedback.as_ref().unwrap().result,
Ok(CopyStatus::Pending(3))
);
view.finish_copy(
&cells,
&(4, Ok(CopyStatus::Confirmed)),
/*current*/ true,
);
assert!(view.copy_feedback.is_none());
}
#[test]
fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() {
let mut snapshots = Vec::new();
for result in [
Ok(CopyStatus::Pending(1)),
Ok(CopyStatus::Busy),
Ok(CopyStatus::Confirmed),
Ok(CopyStatus::Unconfirmed),
Err("unavailable".to_owned()),
@@ -24,14 +97,15 @@ fn copy_feedback_is_right_aligned_and_does_not_claim_terminal_delivery() {
assert!(view.composer_gap_has_content(width, /*hint*/ None, Instant::now()));
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1);
let mut buffer = Buffer::empty(area);
assert!(
assert_eq!(
view.render_composer_gap(
Some(area),
/*hint*/ None,
&mut buffer,
Instant::now()
)
.is_some()
.is_some(),
!matches!(result, Ok(CopyStatus::Pending(_)))
);
assert_eq!(buffer[(width - 1, 0)].symbol(), " ");
snapshots.push(format!("{result:?}, {width} columns\n{}", text(&buffer)));
@@ -59,7 +133,7 @@ fn feedback_releases_navigation_targets_and_expiry_restores_them() {
view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now());
assert!(!text(&buffer).contains("Back to bottom"));
assert!(!view.is_following());
view.copy_feedback.as_mut().unwrap().expires_at = Instant::now();
view.copy_feedback.as_mut().unwrap().expires_at = Some(Instant::now());
buffer.reset();
assert_eq!(
view.render_composer_gap(Some(area), /*hint*/ None, &mut buffer, Instant::now()),
@@ -159,7 +233,7 @@ fn tip_links_follow_alignment_and_release_stale_targets() {
view.show_copy_feedback(&Ok(CopyStatus::Confirmed), /*characters*/ 3);
view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now());
assert!(view.handle_mouse(click, &[]).is_none());
view.copy_feedback.as_mut().unwrap().expires_at = Instant::now();
view.copy_feedback.as_mut().unwrap().expires_at = Some(Instant::now());
view.render_composer_gap(Some(area), Some(&tip), &mut buffer, Instant::now());
assert!(matches!(
view.handle_mouse(click, &[]),
@@ -1,6 +1,7 @@
//! Automatic copies retain completed mouse selections.
use super::*;
use crate::clipboard_copy::CopyStatus;
use crate::history_cell::AgentMarkdownCell;
use crate::transcript_view::tests::cell;
use crate::transcript_view::tests::render;
@@ -49,7 +50,7 @@ fn copy_on_select_waits_for_release_and_retains_selection() {
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
assert!(view.handle_mouse(release, &cells).is_none());
assert!(!view.tick_selection(&cells));
if copied.is_some() {
if let Some(copied) = copied {
let buffer = render(&mut view, &cells, /*width*/ 20, /*height*/ 1);
let highlight = (0..20)
.map(|column| {
@@ -70,6 +71,30 @@ fn copy_on_select_waits_for_release_and_retains_selection() {
^^^^^^^^············
"
);
for result in [
Ok(CopyStatus::Confirmed),
Ok(CopyStatus::Unconfirmed),
Err("clipboard unavailable".to_owned()),
] {
view.copy_selected_text_with(
&cells,
&copied,
/*clear_selection*/ false,
|_| Ok(CopyStatus::Pending(1)),
)
.unwrap();
assert_eq!(
view.finish_copy(&cells, &(1, result.clone()), /*current*/ true),
Some(false)
);
assert_eq!(view.selected_text(&cells).as_deref(), Some(copied.as_str()));
assert_eq!(
view.copy_feedback
.as_ref()
.map(|feedback| (feedback.result, feedback.characters)),
Some((result.map_err(|_| ()), copied.chars().count()))
);
}
}
}
}
@@ -130,7 +130,7 @@ fn copying_selection_at_bottom_does_not_show_back_to_bottom() {
view.begin_selection(&cells, /*column*/ 0, /*row*/ 2, /*clicks*/ 2);
view.end_drag();
let selected = view.selected_text(&cells).expect("selected word");
view.copy_selected_text_with(&cells, &selected, |text| {
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |text| {
assert_eq!(text, "needle");
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
@@ -221,7 +221,7 @@ fn copying_live_selection_retains_the_displayed_revision() {
view.sync_live_tail(/*width*/ 40, /*key*/ None, |_| {
Some(vec![HyperlinkLine::from("changed live text")])
});
view.copy_selected_text_with(&cells, &selected, |_| {
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| {
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
.unwrap();
@@ -186,7 +186,7 @@ fn paused_header_reservation_survives_copy_until_scroll_or_resize() {
);
let selected = view.selected_text(&cells).expect("selected word");
assert_eq!(selected, "done");
view.copy_selected_text_with(&cells, &selected, |_| {
view.copy_selected_text_with(&cells, &selected, /*clear_selection*/ true, |_| {
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
.expect("confirmed copy");
@@ -34,10 +34,15 @@ fn right_click_retains_selection_until_confirmed_and_preserves_reading_position(
panic!("right-click must request a copy without following new output");
};
assert_eq!(selected, "selected text\n");
let copied = view.copy_selected_text_with(&cells, &selected, |copied| {
assert_eq!(copied, selected);
result.clone()
});
let copied = view.copy_selected_text_with(
&cells,
&selected,
/*clear_selection*/ true,
|copied| {
assert_eq!(copied, selected);
result.clone()
},
);
assert_eq!(copied, result);
assert_eq!(
(view.selected_text(&cells), view.position),
+93 -4
View File
@@ -8,7 +8,18 @@ use unicode_segmentation::UnicodeSegmentation;
use super::*;
use crate::text_selection::SelectionUnit;
struct PendingCopy {
id: u64,
start: Anchor,
end: Anchor,
position: Position,
detailed: bool,
follow: bool,
clear_selection: bool,
}
pub(super) struct Selection {
pending_copy: Option<PendingCopy>,
pub(super) snapshot: ViewSnapshot,
pub(super) start: Anchor,
pub(super) end: Anchor,
@@ -50,6 +61,7 @@ impl TranscriptView {
};
let was_following = self.is_following();
self.selection = Some(Selection {
pending_copy: None,
snapshot,
start,
end,
@@ -191,21 +203,98 @@ impl TranscriptView {
Some(text)
}
/// Release selection after confirmed delivery. Failed or unacknowledged terminal writes
/// retain the selected revision so the user can verify pasting and retry.
/// Track delivery for the current selection. Explicit copies release it on confirmation;
/// automatic copies, failures, and unacknowledged terminal writes retain the revision.
pub(crate) fn copy_selected_text_with(
&mut self,
cells: &[Arc<dyn HistoryCell>],
text: &str,
clear_selection: bool,
copy: impl FnOnce(&str) -> Result<crate::clipboard_copy::CopyStatus, String>,
) -> Result<crate::clipboard_copy::CopyStatus, String> {
let result = copy(text);
if matches!(result, Ok(crate::clipboard_copy::CopyStatus::Confirmed)) {
self.end_selection(cells);
match result {
Ok(crate::clipboard_copy::CopyStatus::Confirmed) => {
if clear_selection {
self.end_selection(cells);
}
}
Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => {
if let Some(selection) = &mut self.selection {
selection.pending_copy = Some(PendingCopy {
id,
start: selection.start,
end: selection.end,
position: self.position,
detailed: self.detailed,
follow: false,
clear_selection,
});
}
}
Ok(
crate::clipboard_copy::CopyStatus::Unconfirmed
| crate::clipboard_copy::CopyStatus::Busy,
)
| Err(_) => {}
}
result
}
pub(crate) fn follow_pending_copy(&mut self) {
if let Some(pending) = self
.selection
.as_mut()
.and_then(|s| s.pending_copy.as_mut())
{
pending.follow = true;
}
}
/// A replaced selection has no ticket; moved endpoints cannot consume an old completion.
pub(crate) fn finish_copy(
&mut self,
cells: &[Arc<dyn HistoryCell>],
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
current: bool,
) -> Option<bool> {
// Feedback also belongs to composer copies and survives selection changes. Complete
// its matching ticket even when the selection can no longer consume the result.
if let Some(feedback) = &self.copy_feedback
&& let Ok(crate::clipboard_copy::CopyStatus::Pending(id)) = feedback.result
{
if id == completion.0 {
self.show_copy_feedback(&completion.1, feedback.characters);
} else if id < completion.0 {
// A picker can consume this result and start another copy while the view
// is hidden. A newer completion proves the older request is no longer pending.
self.copy_feedback = None;
}
}
let selection = self.selection.as_mut()?;
if selection.pending_copy.as_ref()?.id != completion.0 {
return None;
}
let pending = selection.pending_copy.take()?;
if !current
|| (pending.start, pending.end) != (selection.start, selection.end)
|| pending.position != self.position
|| pending.detailed != self.detailed
{
return None;
}
let characters = self
.selected_text(cells)
.map_or(/*default*/ 0, |text| text.chars().count());
if pending.clear_selection
&& completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed)
{
self.end_selection(cells);
}
self.show_copy_feedback(&completion.1, characters);
Some(pending.follow && completion.1 == Ok(crate::clipboard_copy::CopyStatus::Confirmed))
}
/// End the pointer gesture without discarding selected text when input ownership changes.
pub(crate) fn end_drag(&mut self) {
self.follow_control = Default::default();
@@ -94,10 +94,15 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
panic!("selection key must request a copy");
};
assert_eq!(text, "selected\tcafé");
let failed = view.copy_selected_text_with(&cells, &text, |text| {
assert_eq!(text, "selected\tcafé");
Err("clipboard unavailable".to_string())
});
let failed = view.copy_selected_text_with(
&cells,
&text,
/*clear_selection*/ true,
|text| {
assert_eq!(text, "selected\tcafé");
Err("clipboard unavailable".to_string())
},
);
assert_eq!(failed, Err("clipboard unavailable".to_string()));
assert_eq!(
view.selected_text(&cells).as_deref(),
@@ -109,10 +114,15 @@ fn copy_shortcuts_clear_selection_only_after_confirmed_delivery() {
else {
panic!("failed copy must remain retryable");
};
let copied = view.copy_selected_text_with(&cells, &text, |text| {
assert_eq!(text, "selected\tcafé");
Ok(status)
});
let copied = view.copy_selected_text_with(
&cells,
&text,
/*clear_selection*/ true,
|text| {
assert_eq!(text, "selected\tcafé");
Ok(status)
},
);
assert_eq!(copied, Ok(status));
assert_eq!(
(view.selected_text(&cells), view.is_search_active()),
@@ -159,7 +169,7 @@ fn command_c_ignores_release_and_copies_only_an_active_selection() {
panic!("Cmd+C must copy the transcript selection");
};
assert_eq!(text, "selected café");
view.copy_selected_text_with(&cells, &text, |_| {
view.copy_selected_text_with(&cells, &text, /*clear_selection*/ true, |_| {
Ok(crate::clipboard_copy::CopyStatus::Confirmed)
})
.unwrap();
@@ -615,3 +625,52 @@ fn loading_older_history_extends_the_snapshot_without_adopting_new_commits() {
render(&mut view, &cells, /*width*/ 32, /*height*/ 6);
assert_eq!(view.selected_text(&cells).as_deref(), Some("selected"));
}
#[test]
fn pending_copy_only_finishes_the_original_selection() {
use crate::clipboard_copy::CopyStatus;
for change in [
"none",
"replace",
"move",
"navigate",
"hide",
"presentation",
] {
let cells = vec![
Arc::new(PlainHistoryCell::new(vec!["selected text".into()])) as Arc<dyn HistoryCell>,
];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 32, /*height*/ 3);
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3);
let selected = view.selected_text(&cells).unwrap();
view.copy_selected_text_with(
&cells,
&selected,
/*clear_selection*/ true,
|_| Ok(CopyStatus::Pending(1)),
)
.unwrap();
view.follow_pending_copy();
match change {
"replace" => {
view.begin_selection(&cells, /*column*/ 0, /*row*/ 0, /*clicks*/ 3)
}
"move" => {
view.selection_key(&cells, KeyCode::Left);
}
"navigate" => view.jump_to_latest(),
"presentation" => {
view.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich)
}
_ => {}
}
let before = view.selected_text(&cells);
let result = view.finish_copy(&cells, &(1, Ok(CopyStatus::Confirmed)), change != "hide");
assert_eq!(result, (change == "none").then_some(/*t*/ true), "{change}");
assert_eq!(
view.selected_text(&cells),
if change == "none" { None } else { before }
);
}
}
@@ -1,8 +1,26 @@
---
source: tui/src/transcript_view/composer_gap_tests.rs
assertion_line: 30
assertion_line: 42
expression: "snapshots.join(\"\\n\\n\")"
---
Ok(Pending(1)), 80 columns
Copying 24 chars…
Ok(Pending(1)), 32 columns
Copying 24 chars…
Ok(Pending(1)), 18 columns
Copying 24 chars…
Ok(Busy), 80 columns
Copy already in progress
Ok(Busy), 32 columns
Copy already in progress
Ok(Busy), 18 columns
Copy in progress
Ok(Confirmed), 80 columns
Copied 24 chars to host clipboard
@@ -0,0 +1,9 @@
---
source: tui/src/transcript_view/composer_gap_tests.rs
assertion_line: 33
expression: "format!(\"Pending after ten seconds\\n{pending_frame}\\nSetup failed\\n{}\",\ntext(&buffer))"
---
Pending after ten seconds
Copying 13 chars…
Setup failed
Copy failed · try again
+3 -3
View File
@@ -648,8 +648,8 @@ pub struct Tui {
// Keep the alternate screen alive when an overlay closes.
owned_screen: bool,
overlay_input: OverlayInput,
// Selection copies survive closing a transcript overlay or startup session picker.
selection_clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
// Copies and native ownership survive closing an overlay or startup picker.
pub(crate) clipboard: crate::clipboard_copy::worker::ClipboardWorker,
// Keeps unmanaged process stderr writes out of the inline viewport.
_stderr_guard: terminal_stderr::TerminalStderrGuard,
}
@@ -718,7 +718,7 @@ impl Tui {
alt_screen_enabled: true,
owned_screen: false,
overlay_input: OverlayInput::Default,
selection_clipboard_lease: None,
clipboard: Default::default(),
_stderr_guard: stderr_guard,
}
}
+3 -17
View File
@@ -1,26 +1,12 @@
//! Keep native clipboard ownership after either transcript overlay consumer is closed.
//! Submit selection copies without waiting for native clipboard or helper I/O.
use super::Tui;
use crate::clipboard_copy::CopyFormat;
use crate::clipboard_copy::CopyOutcome;
use crate::clipboard_copy::CopyStatus;
impl Tui {
pub(crate) fn copy_transcript_selection(&mut self, text: &str) -> Result<CopyStatus, String> {
self.copy_transcript_selection_with(text, |text| {
crate::clipboard_copy::copy_to_clipboard(text, CopyFormat::PlainText)
})
}
fn copy_transcript_selection_with(
&mut self,
text: &str,
copy: impl FnOnce(&str) -> Result<CopyOutcome, String>,
) -> Result<CopyStatus, String> {
Ok(copy(text)?.store(&mut self.selection_clipboard_lease))
self.clipboard
.copy(text.into(), CopyFormat::PlainText, self.frame_requester())
}
}
#[cfg(test)]
#[path = "selection_clipboard_tests.rs"]
mod tests;
@@ -1,54 +0,0 @@
//! Closing nested or App transcript overlays must not release Linux native ownership.
use super::*;
use crate::clipboard_copy::ClipboardLease;
use crate::history_cell::PlainHistoryCell;
use crate::keymap::RuntimeKeymap;
use crate::pager_overlay::Overlay;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[tokio::test]
async fn selection_clipboard_survives_overlay_close_and_later_terminal_requests()
-> std::io::Result<()> {
let mut tui = crate::tui::test_support::make_test_tui()?;
for nested in [false, true] {
let mut overlay = Overlay::new_transcript(
vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))],
RuntimeKeymap::defaults().pager,
/*copy_on_select*/ false,
);
assert_eq!(
tui.copy_transcript_selection_with("selected", |text| {
assert_eq!(text, "selected");
Ok(CopyOutcome::Copied(Some(ClipboardLease::test())))
}),
Ok(CopyStatus::Confirmed)
);
overlay.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
)?;
assert!(overlay.is_done());
drop(overlay);
if !nested {
tui.leave_alt_screen()?;
}
assert!(tui.selection_clipboard_lease.is_some());
for outcome in [CopyOutcome::Requested, CopyOutcome::Copied(None)] {
tui.copy_transcript_selection_with("retry", |_| Ok(outcome))
.unwrap();
assert!(tui.selection_clipboard_lease.is_some());
}
assert_eq!(
tui.copy_transcript_selection_with("retry", |_| Err("blocked".into())),
Err("blocked".into())
);
assert!(tui.selection_clipboard_lease.is_some());
}
Ok(())
}