Skip to content

Commit 181ff89

Browse files
authored
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
1 parent 5678213 commit 181ff89

File tree

8 files changed

+319
-46
lines changed

8 files changed

+319
-46
lines changed

codex-rs/tui2/src/app.rs

Lines changed: 36 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use crate::app_event::AppEvent;
33
use crate::app_event_sender::AppEventSender;
44
use crate::bottom_pane::ApprovalRequest;
55
use crate::chatwidget::ChatWidget;
6-
use crate::clipboard_copy;
76
use crate::custom_terminal::Frame;
87
use crate::diff_render::DiffSummary;
98
use crate::exec_command::strip_bash_lc_and_escape;
@@ -17,6 +16,8 @@ use crate::pager_overlay::Overlay;
1716
use crate::render::highlight::highlight_bash_to_lines;
1817
use crate::render::renderable::Renderable;
1918
use crate::resume_picker::ResumeSelection;
19+
use crate::transcript_copy_action::TranscriptCopyAction;
20+
use crate::transcript_copy_action::TranscriptCopyFeedback;
2021
use crate::transcript_copy_ui::TranscriptCopyUi;
2122
use crate::transcript_multi_click::TranscriptMultiClick;
2223
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
@@ -335,6 +336,7 @@ pub(crate) struct App {
335336
transcript_view_top: usize,
336337
transcript_total_lines: usize,
337338
transcript_copy_ui: TranscriptCopyUi,
339+
transcript_copy_action: TranscriptCopyAction,
338340

339341
// Pager overlay state (Transcript or Static like Diff)
340342
pub(crate) overlay: Option<Overlay>,
@@ -500,6 +502,7 @@ impl App {
500502
transcript_view_top: 0,
501503
transcript_total_lines: 0,
502504
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
505+
transcript_copy_action: TranscriptCopyAction::default(),
503506
overlay: None,
504507
deferred_history_lines: Vec::new(),
505508
has_emitted_history_lines: false,
@@ -667,11 +670,14 @@ impl App {
667670
self.transcript_total_lines,
668671
))
669672
};
673+
let copy_selection_key = self.copy_selection_key();
674+
let copy_feedback = self.transcript_copy_feedback_for_footer();
670675
self.chat_widget.set_transcript_ui_state(
671676
transcript_scrolled,
672677
selection_active,
673678
scroll_position,
674-
self.copy_selection_key(),
679+
copy_selection_key,
680+
copy_feedback,
675681
);
676682
}
677683
}
@@ -893,7 +899,14 @@ impl App {
893899
.transcript_copy_ui
894900
.hit_test(mouse_event.column, mouse_event.row)
895901
{
896-
self.copy_transcript_selection(tui);
902+
if self.transcript_copy_action.copy_and_handle(
903+
tui,
904+
chat_height,
905+
&self.transcript_cells,
906+
self.transcript_selection,
907+
) {
908+
self.transcript_selection = TranscriptSelection::default();
909+
}
897910
return;
898911
}
899912

@@ -1239,46 +1252,8 @@ impl App {
12391252
}
12401253
}
12411254

1242-
/// Copy the currently selected transcript region to the system clipboard.
1243-
///
1244-
/// The selection is defined in terms of flattened wrapped transcript line
1245-
/// indices and columns, and this method reconstructs the same wrapped
1246-
/// transcript used for on-screen rendering so the copied text closely
1247-
/// matches the highlighted region.
1248-
///
1249-
/// Important: copy operates on the selection's full content-relative range,
1250-
/// not just the current viewport. A selection can extend outside the visible
1251-
/// region (for example, by scrolling after selecting, or by selecting while
1252-
/// autoscrolling), and we still want the clipboard payload to reflect the
1253-
/// entire selected transcript.
1254-
fn copy_transcript_selection(&mut self, tui: &tui::Tui) {
1255-
let size = tui.terminal.last_known_screen_size;
1256-
let width = size.width;
1257-
let height = size.height;
1258-
if width == 0 || height == 0 {
1259-
return;
1260-
}
1261-
1262-
let chat_height = self.chat_widget.desired_height(width);
1263-
if chat_height >= height {
1264-
return;
1265-
}
1266-
1267-
let transcript_height = height.saturating_sub(chat_height);
1268-
if transcript_height == 0 {
1269-
return;
1270-
}
1271-
1272-
let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
1273-
&self.transcript_cells,
1274-
self.transcript_selection,
1275-
width,
1276-
) else {
1277-
return;
1278-
};
1279-
if let Err(err) = clipboard_copy::copy_text(text) {
1280-
tracing::error!(error = %err, "failed to copy selection to clipboard");
1281-
}
1255+
fn transcript_copy_feedback_for_footer(&mut self) -> Option<TranscriptCopyFeedback> {
1256+
self.transcript_copy_action.footer_feedback()
12821257
}
12831258

12841259
fn copy_selection_key(&self) -> crate::key_hint::KeyBinding {
@@ -1902,7 +1877,22 @@ impl App {
19021877
kind: KeyEventKind::Press | KeyEventKind::Repeat,
19031878
..
19041879
} if self.transcript_copy_ui.is_copy_key(ch, modifiers) => {
1905-
self.copy_transcript_selection(tui);
1880+
let size = tui.terminal.last_known_screen_size;
1881+
let width = size.width;
1882+
let height = size.height;
1883+
if width == 0 || height == 0 {
1884+
return;
1885+
}
1886+
1887+
let chat_height = self.chat_widget.desired_height(width);
1888+
if self.transcript_copy_action.copy_and_handle(
1889+
tui,
1890+
chat_height,
1891+
&self.transcript_cells,
1892+
self.transcript_selection,
1893+
) {
1894+
self.transcript_selection = TranscriptSelection::default();
1895+
}
19061896
}
19071897
KeyEvent {
19081898
code: KeyCode::PageUp,
@@ -2093,6 +2083,7 @@ mod tests {
20932083
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
20942084
CopySelectionShortcut::CtrlShiftC,
20952085
),
2086+
transcript_copy_action: TranscriptCopyAction::default(),
20962087
overlay: None,
20972088
deferred_history_lines: Vec::new(),
20982089
has_emitted_history_lines: false,
@@ -2144,6 +2135,7 @@ mod tests {
21442135
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
21452136
CopySelectionShortcut::CtrlShiftC,
21462137
),
2138+
transcript_copy_action: TranscriptCopyAction::default(),
21472139
overlay: None,
21482140
deferred_history_lines: Vec::new(),
21492141
has_emitted_history_lines: false,

codex-rs/tui2/src/bottom_pane/chat_composer.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::key_hint;
22
use crate::key_hint::KeyBinding;
33
use crate::key_hint::has_ctrl_or_alt;
4+
use crate::transcript_copy_action::TranscriptCopyFeedback;
45
use crossterm::event::KeyCode;
56
use crossterm::event::KeyEvent;
67
use crossterm::event::KeyEventKind;
@@ -124,6 +125,7 @@ pub(crate) struct ChatComposer {
124125
transcript_selection_active: bool,
125126
transcript_scroll_position: Option<(usize, usize)>,
126127
transcript_copy_selection_key: KeyBinding,
128+
transcript_copy_feedback: Option<TranscriptCopyFeedback>,
127129
skills: Option<Vec<SkillMetadata>>,
128130
dismissed_skill_popup_token: Option<String>,
129131
}
@@ -176,6 +178,7 @@ impl ChatComposer {
176178
transcript_selection_active: false,
177179
transcript_scroll_position: None,
178180
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
181+
transcript_copy_feedback: None,
179182
skills: None,
180183
dismissed_skill_popup_token: None,
181184
};
@@ -1545,6 +1548,7 @@ impl ChatComposer {
15451548
transcript_selection_active: self.transcript_selection_active,
15461549
transcript_scroll_position: self.transcript_scroll_position,
15471550
transcript_copy_selection_key: self.transcript_copy_selection_key,
1551+
transcript_copy_feedback: self.transcript_copy_feedback,
15481552
}
15491553
}
15501554

@@ -1577,11 +1581,13 @@ impl ChatComposer {
15771581
selection_active: bool,
15781582
scroll_position: Option<(usize, usize)>,
15791583
copy_selection_key: KeyBinding,
1584+
copy_feedback: Option<TranscriptCopyFeedback>,
15801585
) -> bool {
15811586
if self.transcript_scrolled == scrolled
15821587
&& self.transcript_selection_active == selection_active
15831588
&& self.transcript_scroll_position == scroll_position
15841589
&& self.transcript_copy_selection_key == copy_selection_key
1590+
&& self.transcript_copy_feedback == copy_feedback
15851591
{
15861592
return false;
15871593
}
@@ -1590,6 +1596,7 @@ impl ChatComposer {
15901596
self.transcript_selection_active = selection_active;
15911597
self.transcript_scroll_position = scroll_position;
15921598
self.transcript_copy_selection_key = copy_selection_key;
1599+
self.transcript_copy_feedback = copy_feedback;
15931600
true
15941601
}
15951602

codex-rs/tui2/src/bottom_pane/footer.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::key_hint;
44
use crate::key_hint::KeyBinding;
55
use crate::render::line_utils::prefix_lines;
66
use crate::status::format_tokens_compact;
7+
use crate::transcript_copy_action::TranscriptCopyFeedback;
78
use crate::ui_consts::FOOTER_INDENT_COLS;
89
use crossterm::event::KeyCode;
910
use ratatui::buffer::Buffer;
@@ -26,6 +27,7 @@ pub(crate) struct FooterProps {
2627
pub(crate) transcript_selection_active: bool,
2728
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
2829
pub(crate) transcript_copy_selection_key: KeyBinding,
30+
pub(crate) transcript_copy_feedback: Option<TranscriptCopyFeedback>,
2931
}
3032

3133
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -80,11 +82,26 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
8082
}
8183

8284
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
85+
fn apply_copy_feedback(lines: &mut [Line<'static>], feedback: Option<TranscriptCopyFeedback>) {
86+
let Some(line) = lines.first_mut() else {
87+
return;
88+
};
89+
let Some(feedback) = feedback else {
90+
return;
91+
};
92+
93+
line.push_span(" · ".dim());
94+
match feedback {
95+
TranscriptCopyFeedback::Copied => line.push_span("Copied".green().bold()),
96+
TranscriptCopyFeedback::Failed => line.push_span("Copy failed".red().bold()),
97+
}
98+
}
99+
83100
// Show the context indicator on the left, appended after the primary hint
84101
// (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when
85102
// the shortcut hint is hidden). Hide it only for the multi-line
86103
// ShortcutOverlay.
87-
match props.mode {
104+
let mut lines = match props.mode {
88105
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
89106
is_task_running: props.is_task_running,
90107
})],
@@ -139,7 +156,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
139156
props.context_window_percent,
140157
props.context_window_used_tokens,
141158
)],
142-
}
159+
};
160+
apply_copy_feedback(&mut lines, props.transcript_copy_feedback);
161+
lines
143162
}
144163

145164
#[derive(Clone, Copy, Debug)]
@@ -469,6 +488,7 @@ mod tests {
469488
transcript_selection_active: false,
470489
transcript_scroll_position: None,
471490
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
491+
transcript_copy_feedback: None,
472492
},
473493
);
474494

@@ -485,6 +505,7 @@ mod tests {
485505
transcript_selection_active: true,
486506
transcript_scroll_position: Some((3, 42)),
487507
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
508+
transcript_copy_feedback: None,
488509
},
489510
);
490511

@@ -501,6 +522,7 @@ mod tests {
501522
transcript_selection_active: false,
502523
transcript_scroll_position: None,
503524
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
525+
transcript_copy_feedback: None,
504526
},
505527
);
506528

@@ -517,6 +539,7 @@ mod tests {
517539
transcript_selection_active: false,
518540
transcript_scroll_position: None,
519541
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
542+
transcript_copy_feedback: None,
520543
},
521544
);
522545

@@ -533,6 +556,7 @@ mod tests {
533556
transcript_selection_active: false,
534557
transcript_scroll_position: None,
535558
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
559+
transcript_copy_feedback: None,
536560
},
537561
);
538562

@@ -549,6 +573,7 @@ mod tests {
549573
transcript_selection_active: false,
550574
transcript_scroll_position: None,
551575
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
576+
transcript_copy_feedback: None,
552577
},
553578
);
554579

@@ -565,6 +590,7 @@ mod tests {
565590
transcript_selection_active: false,
566591
transcript_scroll_position: None,
567592
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
593+
transcript_copy_feedback: None,
568594
},
569595
);
570596

@@ -581,6 +607,7 @@ mod tests {
581607
transcript_selection_active: false,
582608
transcript_scroll_position: None,
583609
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
610+
transcript_copy_feedback: None,
584611
},
585612
);
586613

@@ -597,6 +624,24 @@ mod tests {
597624
transcript_selection_active: false,
598625
transcript_scroll_position: None,
599626
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
627+
transcript_copy_feedback: None,
628+
},
629+
);
630+
631+
snapshot_footer(
632+
"footer_copy_feedback_copied",
633+
FooterProps {
634+
mode: FooterMode::ShortcutSummary,
635+
esc_backtrack_hint: false,
636+
use_shift_enter_hint: false,
637+
is_task_running: false,
638+
context_window_percent: None,
639+
context_window_used_tokens: None,
640+
transcript_scrolled: false,
641+
transcript_selection_active: false,
642+
transcript_scroll_position: None,
643+
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
644+
transcript_copy_feedback: Some(TranscriptCopyFeedback::Copied),
600645
},
601646
);
602647
}

codex-rs/tui2/src/bottom_pane/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,14 @@ impl BottomPane {
388388
selection_active: bool,
389389
scroll_position: Option<(usize, usize)>,
390390
copy_selection_key: crate::key_hint::KeyBinding,
391+
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
391392
) {
392393
let updated = self.composer.set_transcript_ui_state(
393394
scrolled,
394395
selection_active,
395396
scroll_position,
396397
copy_selection_key,
398+
copy_feedback,
397399
);
398400
if updated {
399401
self.request_redraw();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: tui2/src/bottom_pane/footer.rs
3+
assertion_line: 473
4+
expression: terminal.backend()
5+
---
6+
" 100% context left · ? for shortcuts · Copied "

codex-rs/tui2/src/chatwidget.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3096,12 +3096,14 @@ impl ChatWidget {
30963096
selection_active: bool,
30973097
scroll_position: Option<(usize, usize)>,
30983098
copy_selection_key: crate::key_hint::KeyBinding,
3099+
copy_feedback: Option<crate::transcript_copy_action::TranscriptCopyFeedback>,
30993100
) {
31003101
self.bottom_pane.set_transcript_ui_state(
31013102
scrolled,
31023103
selection_active,
31033104
scroll_position,
31043105
copy_selection_key,
3106+
copy_feedback,
31053107
);
31063108
}
31073109

codex-rs/tui2/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ mod terminal_palette;
7777
mod text_formatting;
7878
mod tooltips;
7979
mod transcript_copy;
80+
mod transcript_copy_action;
8081
mod transcript_copy_ui;
8182
mod transcript_multi_click;
8283
mod transcript_render;

0 commit comments

Comments
 (0)