mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -69,7 +69,7 @@ impl ChatWidget {
|
||||
);
|
||||
}
|
||||
if choices.is_empty() {
|
||||
self.copy_last_agent_markdown();
|
||||
self.prepare_last_response_copy();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
@@ -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(
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
+6
@@ -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
|
||||
@@ -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(()));
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
|
||||
+9
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user