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:
Josh McKinney authored and GitHub committed 2026-01-04 22:05:18 -08:00
1 parent 5678213058
commit 181ff89cbd
8 files changed
+319 -46

No files matched your search

@@ -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
}
+47 -2
View File
@@ -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),
},
);
}
+2
View File
@@ -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 "