diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index c6006889787..84d16415d74 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -3,7 +3,6 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; -use crate::clipboard_copy; use crate::custom_terminal::Frame; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; @@ -17,6 +16,8 @@ use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::ResumeSelection; +use crate::transcript_copy_action::TranscriptCopyAction; +use crate::transcript_copy_action::TranscriptCopyFeedback; use crate::transcript_copy_ui::TranscriptCopyUi; use crate::transcript_multi_click::TranscriptMultiClick; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; @@ -335,6 +336,7 @@ pub(crate) struct App { transcript_view_top: usize, transcript_total_lines: usize, transcript_copy_ui: TranscriptCopyUi, + transcript_copy_action: TranscriptCopyAction, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -500,6 +502,7 @@ impl App { transcript_view_top: 0, transcript_total_lines: 0, transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut), + transcript_copy_action: TranscriptCopyAction::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -667,11 +670,14 @@ impl App { self.transcript_total_lines, )) }; + let copy_selection_key = self.copy_selection_key(); + let copy_feedback = self.transcript_copy_feedback_for_footer(); self.chat_widget.set_transcript_ui_state( transcript_scrolled, selection_active, scroll_position, - self.copy_selection_key(), + copy_selection_key, + copy_feedback, ); } } @@ -893,7 +899,14 @@ impl App { .transcript_copy_ui .hit_test(mouse_event.column, mouse_event.row) { - self.copy_transcript_selection(tui); + if self.transcript_copy_action.copy_and_handle( + tui, + chat_height, + &self.transcript_cells, + self.transcript_selection, + ) { + self.transcript_selection = TranscriptSelection::default(); + } return; } @@ -1239,46 +1252,8 @@ impl App { } } - /// Copy the currently selected transcript region to the system clipboard. - /// - /// The selection is defined in terms of flattened wrapped transcript line - /// indices and columns, and this method reconstructs the same wrapped - /// transcript used for on-screen rendering so the copied text closely - /// matches the highlighted region. - /// - /// Important: copy operates on the selection's full content-relative range, - /// not just the current viewport. A selection can extend outside the visible - /// region (for example, by scrolling after selecting, or by selecting while - /// autoscrolling), and we still want the clipboard payload to reflect the - /// entire selected transcript. - fn copy_transcript_selection(&mut self, tui: &tui::Tui) { - let size = tui.terminal.last_known_screen_size; - let width = size.width; - let height = size.height; - if width == 0 || height == 0 { - return; - } - - let chat_height = self.chat_widget.desired_height(width); - if chat_height >= height { - return; - } - - let transcript_height = height.saturating_sub(chat_height); - if transcript_height == 0 { - return; - } - - let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells( - &self.transcript_cells, - self.transcript_selection, - width, - ) else { - return; - }; - if let Err(err) = clipboard_copy::copy_text(text) { - tracing::error!(error = %err, "failed to copy selection to clipboard"); - } + fn transcript_copy_feedback_for_footer(&mut self) -> Option { + self.transcript_copy_action.footer_feedback() } fn copy_selection_key(&self) -> crate::key_hint::KeyBinding { @@ -1902,7 +1877,22 @@ impl App { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } if self.transcript_copy_ui.is_copy_key(ch, modifiers) => { - self.copy_transcript_selection(tui); + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if self.transcript_copy_action.copy_and_handle( + tui, + chat_height, + &self.transcript_cells, + self.transcript_selection, + ) { + self.transcript_selection = TranscriptSelection::default(); + } } KeyEvent { code: KeyCode::PageUp, @@ -2093,6 +2083,7 @@ mod tests { transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( CopySelectionShortcut::CtrlShiftC, ), + transcript_copy_action: TranscriptCopyAction::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -2144,6 +2135,7 @@ mod tests { transcript_copy_ui: TranscriptCopyUi::new_with_shortcut( CopySelectionShortcut::CtrlShiftC, ), + transcript_copy_action: TranscriptCopyAction::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index f3472b29336..0073173fdc7 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1,6 +1,7 @@ use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; +use crate::transcript_copy_action::TranscriptCopyFeedback; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -124,6 +125,7 @@ pub(crate) struct ChatComposer { transcript_selection_active: bool, transcript_scroll_position: Option<(usize, usize)>, transcript_copy_selection_key: KeyBinding, + transcript_copy_feedback: Option, skills: Option>, dismissed_skill_popup_token: Option, } @@ -176,6 +178,7 @@ impl ChatComposer { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, skills: None, dismissed_skill_popup_token: None, }; @@ -1545,6 +1548,7 @@ impl ChatComposer { transcript_selection_active: self.transcript_selection_active, transcript_scroll_position: self.transcript_scroll_position, transcript_copy_selection_key: self.transcript_copy_selection_key, + transcript_copy_feedback: self.transcript_copy_feedback, } } @@ -1577,11 +1581,13 @@ impl ChatComposer { selection_active: bool, scroll_position: Option<(usize, usize)>, copy_selection_key: KeyBinding, + copy_feedback: Option, ) -> bool { if self.transcript_scrolled == scrolled && self.transcript_selection_active == selection_active && self.transcript_scroll_position == scroll_position && self.transcript_copy_selection_key == copy_selection_key + && self.transcript_copy_feedback == copy_feedback { return false; } @@ -1590,6 +1596,7 @@ impl ChatComposer { self.transcript_selection_active = selection_active; self.transcript_scroll_position = scroll_position; self.transcript_copy_selection_key = copy_selection_key; + self.transcript_copy_feedback = copy_feedback; true } diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 57bffd5639b..f4ead67be61 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -4,6 +4,7 @@ use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::line_utils::prefix_lines; use crate::status::format_tokens_compact; +use crate::transcript_copy_action::TranscriptCopyFeedback; use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; @@ -26,6 +27,7 @@ pub(crate) struct FooterProps { pub(crate) transcript_selection_active: bool, pub(crate) transcript_scroll_position: Option<(usize, usize)>, pub(crate) transcript_copy_selection_key: KeyBinding, + pub(crate) transcript_copy_feedback: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -80,11 +82,26 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { } fn footer_lines(props: FooterProps) -> Vec> { + fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option) { + let Some(line) = lines.first_mut() else { + return; + }; + let Some(feedback) = feedback else { + return; + }; + + line.push_span(" · ".dim()); + match feedback { + TranscriptCopyFeedback::Copied => line.push_span("Copied".green().bold()), + TranscriptCopyFeedback::Failed => line.push_span("Copy failed".red().bold()), + } + } + // Show the context indicator on the left, appended after the primary hint // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when // the shortcut hint is hidden). Hide it only for the multi-line // ShortcutOverlay. - match props.mode { + let mut lines = match props.mode { FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { is_task_running: props.is_task_running, })], @@ -139,7 +156,9 @@ fn footer_lines(props: FooterProps) -> Vec> { props.context_window_percent, props.context_window_used_tokens, )], - } + }; + apply_copy_feedback(&mut lines, props.transcript_copy_feedback); + lines } #[derive(Clone, Copy, Debug)] @@ -469,6 +488,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -485,6 +505,7 @@ mod tests { transcript_selection_active: true, transcript_scroll_position: Some((3, 42)), transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -501,6 +522,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -517,6 +539,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -533,6 +556,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -549,6 +573,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -565,6 +590,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -581,6 +607,7 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, }, ); @@ -597,6 +624,24 @@ mod tests { transcript_selection_active: false, transcript_scroll_position: None, transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: None, + }, + ); + + snapshot_footer( + "footer_copy_feedback_copied", + FooterProps { + mode: FooterMode::ShortcutSummary, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + context_window_used_tokens: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')), + transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied), }, ); } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 466da8cda8e..2ebd0715e7d 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -388,12 +388,14 @@ impl BottomPane { selection_active: bool, scroll_position: Option<(usize, usize)>, copy_selection_key: crate::key_hint::KeyBinding, + copy_feedback: Option, ) { let updated = self.composer.set_transcript_ui_state( scrolled, selection_active, scroll_position, copy_selection_key, + copy_feedback, ); if updated { self.request_redraw(); diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap new file mode 100644 index 00000000000..77a9306adc7 --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_copy_feedback_copied.snap @@ -0,0 +1,6 @@ +--- +source: tui2/src/bottom_pane/footer.rs +assertion_line: 473 +expression: terminal.backend() +--- +" 100% context left · ? for shortcuts · Copied " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 681deb9cdbb..d92cf602140 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3096,12 +3096,14 @@ impl ChatWidget { selection_active: bool, scroll_position: Option<(usize, usize)>, copy_selection_key: crate::key_hint::KeyBinding, + copy_feedback: Option, ) { self.bottom_pane.set_transcript_ui_state( scrolled, selection_active, scroll_position, copy_selection_key, + copy_feedback, ); } diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index f549ab783bf..3c5ac92f6c3 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -77,6 +77,7 @@ mod terminal_palette; mod text_formatting; mod tooltips; mod transcript_copy; +mod transcript_copy_action; mod transcript_copy_ui; mod transcript_multi_click; mod transcript_render; diff --git a/codex-rs/tui2/src/transcript_copy_action.rs b/codex-rs/tui2/src/transcript_copy_action.rs new file mode 100644 index 00000000000..04ea239b024 --- /dev/null +++ b/codex-rs/tui2/src/transcript_copy_action.rs @@ -0,0 +1,218 @@ +//! Performs "copy selection" and manages transient UI feedback. +//! +//! `transcript_copy` is intentionally pure: it reconstructs clipboard text from a +//! [`TranscriptSelection`], preserving wrapping, indentation, and Markdown markers. +//! +//! This module is the side-effecting layer on top of that pure logic: +//! - writes the reconstructed text to the system clipboard +//! - stores short-lived state so the footer can show `"Copied"` / `"Copy failed"` +//! - schedules redraws so feedback appears promptly and then clears itself +//! +//! Keeping these responsibilities separate reduces cognitive load: +//! - `transcript_copy` answers *what text should be copied?* +//! - `transcript_copy_action` answers *do the copy and tell the user it happened* + +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use crate::history_cell::HistoryCell; +use crate::transcript_selection::TranscriptSelection; +use crate::tui; + +/// User-visible feedback shown briefly after a copy attempt. +/// +/// The footer renders this value when present, and it expires automatically. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TranscriptCopyFeedback { + /// Copy succeeded and the clipboard was updated. + Copied, + /// Copy failed (typically due to OS clipboard integration issues). + Failed, +} + +/// The outcome of attempting to copy the current selection. +/// +/// This is a compact signal for UI code: +/// - `NoSelection` means the action is a no-op (nothing to dismiss). +/// - `Copied`/`Failed` mean the action was triggered and the selection should be dismissed. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum CopySelectionOutcome { + /// No active selection exists (or the terminal is too small to compute one). + NoSelection, + /// Clipboard write succeeded. + Copied, + /// Clipboard write failed. + Failed, +} + +const TRANSCRIPT_COPY_FEEDBACK_DURATION: Duration = Duration::from_millis(1500); + +#[derive(Debug, Clone, Copy)] +struct TranscriptCopyFeedbackState { + kind: TranscriptCopyFeedback, + expires_at: Instant, +} + +/// Performs the copy action and tracks transient footer feedback. +/// +/// `App` owns one instance and calls [`Self::copy_and_handle`] when the user triggers "copy +/// selection" (either via the on-screen copy pill or the keyboard shortcut). +#[derive(Debug, Default)] +pub(crate) struct TranscriptCopyAction { + feedback: Option, +} + +impl TranscriptCopyAction { + /// Attempt to copy the current selection and record feedback. + /// + /// Returns `true` when a copy attempt was made (success or failure). Callers should treat that + /// as a signal to dismiss the selection highlight. + pub(crate) fn copy_and_handle( + &mut self, + tui: &mut tui::Tui, + chat_height: u16, + transcript_cells: &[Arc], + transcript_selection: TranscriptSelection, + ) -> bool { + let outcome = + copy_transcript_selection(tui, chat_height, transcript_cells, transcript_selection); + self.handle_copy_outcome(tui, outcome) + } + + /// Return footer feedback to render for the current frame, if any. + /// + /// This is called from `App`'s render loop. It clears expired feedback lazily so callers do + /// not need separate timer plumbing. + pub(crate) fn footer_feedback(&mut self) -> Option { + let state = self.feedback?; + + if Instant::now() >= state.expires_at { + self.feedback = None; + return None; + } + + Some(state.kind) + } + + /// Record the outcome of a copy attempt and schedule redraws. + /// + /// Returns `true` when a copy attempt happened (success or failure). This is the signal to + /// dismiss the selection highlight. + pub(crate) fn handle_copy_outcome( + &mut self, + tui: &mut tui::Tui, + outcome: CopySelectionOutcome, + ) -> bool { + match outcome { + CopySelectionOutcome::NoSelection => false, + CopySelectionOutcome::Copied => { + self.set_feedback(tui, TranscriptCopyFeedback::Copied); + true + } + CopySelectionOutcome::Failed => { + self.set_feedback(tui, TranscriptCopyFeedback::Failed); + true + } + } + } + + /// Store feedback state and schedule a redraw for its appearance + expiration. + fn set_feedback(&mut self, tui: &mut tui::Tui, kind: TranscriptCopyFeedback) { + let expires_at = Instant::now() + .checked_add(TRANSCRIPT_COPY_FEEDBACK_DURATION) + .unwrap_or_else(Instant::now); + self.feedback = Some(TranscriptCopyFeedbackState { kind, expires_at }); + + tui.frame_requester().schedule_frame(); + tui.frame_requester() + .schedule_frame_in(TRANSCRIPT_COPY_FEEDBACK_DURATION); + } +} + +/// Copy the current transcript selection to the system clipboard. +/// +/// This function ties together layout validation, selection-to-text reconstruction via +/// `transcript_copy`, and the actual clipboard write. +pub(crate) fn copy_transcript_selection( + tui: &tui::Tui, + chat_height: u16, + transcript_cells: &[Arc], + transcript_selection: TranscriptSelection, +) -> CopySelectionOutcome { + // This function is intentionally "dumb plumbing": + // - validate layout prerequisites + // - reconstruct clipboard text (`transcript_copy`) + // - write to clipboard + // + // UI state management (feedback + redraw scheduling) lives in `TranscriptCopyAction`. + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return CopySelectionOutcome::NoSelection; + } + + if chat_height >= height { + return CopySelectionOutcome::NoSelection; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return CopySelectionOutcome::NoSelection; + } + + let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells( + transcript_cells, + transcript_selection, + width, + ) else { + return CopySelectionOutcome::NoSelection; + }; + + if let Err(err) = crate::clipboard_copy::copy_text(text) { + tracing::error!(error = %err, "failed to copy selection to clipboard"); + return CopySelectionOutcome::Failed; + } + + CopySelectionOutcome::Copied +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn footer_feedback_returns_value_before_expiration() { + let mut action = TranscriptCopyAction { + feedback: Some(TranscriptCopyFeedbackState { + kind: TranscriptCopyFeedback::Copied, + expires_at: Instant::now() + Duration::from_secs(10), + }), + }; + + assert_eq!( + action.footer_feedback(), + Some(TranscriptCopyFeedback::Copied) + ); + assert_eq!( + action.footer_feedback(), + Some(TranscriptCopyFeedback::Copied) + ); + } + + #[test] + fn footer_feedback_clears_after_expiration() { + let mut action = TranscriptCopyAction { + feedback: Some(TranscriptCopyFeedbackState { + kind: TranscriptCopyFeedback::Copied, + expires_at: Instant::now() - Duration::from_secs(1), + }), + }; + + assert_eq!(action.footer_feedback(), None); + assert!(action.feedback.is_none()); + assert_eq!(action.footer_feedback(), None); + } +}