tui2: copy selection dismisses highlight (#8718)
Clicking the transcript copy pill or pressing the copy shortcut now
copies the selected transcript text and clears the highlight.
Show transient footer feedback ("Copied"/"Copy failed") after a copy
attempt, with logic in transcript_copy_action to keep app.rs smaller and
closer to tui for long-term diffs.
Update footer snapshots and add tiny unit tests for feedback expiry.
https://github.com/user-attachments/assets/c36c8163-11c5-476b-b388-e6fbe0ff6034
This commit is contained in:
parent
5678213058
commit
181ff89cbd
8 changed files with 319 additions and 46 deletions
|
|
@ -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<Overlay>,
|
||||
|
|
@ -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<TranscriptCopyFeedback> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<TranscriptCopyFeedback>,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
dismissed_skill_popup_token: Option<String>,
|
||||
}
|
||||
|
|
@ -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<TranscriptCopyFeedback>,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TranscriptCopyFeedback>,
|
||||
}
|
||||
|
||||
#[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<Line<'static>> {
|
||||
fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option<TranscriptCopyFeedback>) {
|
||||
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<Line<'static>> {
|
|||
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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,12 +388,14 @@ impl BottomPane {
|
|||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
|
||||
) {
|
||||
let updated = self.composer.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
copy_feedback,
|
||||
);
|
||||
if updated {
|
||||
self.request_redraw();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
source: tui2/src/bottom_pane/footer.rs
|
||||
assertion_line: 473
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" 100% context left · ? for shortcuts · Copied "
|
||||
|
|
@ -3096,12 +3096,14 @@ impl ChatWidget {
|
|||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
|
||||
) {
|
||||
self.bottom_pane.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
copy_feedback,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
218
codex-rs/tui2/src/transcript_copy_action.rs
Normal file
218
codex-rs/tui2/src/transcript_copy_action.rs
Normal file
|
|
@ -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<TranscriptCopyFeedbackState>,
|
||||
}
|
||||
|
||||
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<dyn HistoryCell>],
|
||||
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<TranscriptCopyFeedback> {
|
||||
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<dyn HistoryCell>],
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue