feat(tui2): add copy selection shortcut + UI affordance (#8462)
- Detect Ctrl+Shift+C vs VS Code Ctrl+Y and surface in footer hints - Render clickable “⧉ copy” pill near transcript selection (hidden while dragging) - Handle copy hotkey + click to copy selection - Document updated copy UX VSCode: <img width="1095" height="413" alt="image" src="https://github.com/user-attachments/assets/84be0c82-4762-4c3e-80a4-c751c078bdaa" /> Ghosty: <img width="505" height="68" alt="image" src="https://github.com/user-attachments/assets/109cc1a1-f029-4f7e-a141-4c6ed2da7338" />
This commit is contained in:
parent
277babba79
commit
414fbe0da9
9 changed files with 596 additions and 14 deletions
|
|
@ -440,6 +440,8 @@ feedback are already implemented:
|
|||
stable, and follow mode resumes after the selection is cleared.
|
||||
- Copy operates on the full selection range (including offscreen lines), using the same wrapping as
|
||||
on-screen rendering.
|
||||
- Copy selection uses `Ctrl+Shift+C` (VS Code uses `Ctrl+Y` because `Ctrl+Shift+C` is unavailable in
|
||||
the terminal) and shows an on-screen “copy” affordance near the selection.
|
||||
|
||||
### 10.2 Roadmap (prioritized)
|
||||
|
||||
|
|
@ -462,9 +464,6 @@ Vim) behavior as we can while still owning the viewport.
|
|||
|
||||
- **Streaming wrapping polish.** Ensure all streaming paths use display-time wrapping only, and add
|
||||
tests that cover resizing after streaming has started.
|
||||
- **Copy shortcut and discoverability.** Switch copy from `Ctrl+Y` to `Ctrl+Shift+C`, and add an
|
||||
on-screen copy affordance (e.g. a small button near the selection) that also displays the
|
||||
shortcut.
|
||||
- **Selection semantics.** Define and implement selection behavior across multi-step output (and
|
||||
whether step boundaries should be copy boundaries), while continuing to exclude the left gutter
|
||||
from copied text.
|
||||
|
|
@ -532,9 +531,9 @@ explicit discussion before we commit to further UI changes.
|
|||
|
||||
- **Selection affordances.**
|
||||
|
||||
- Today, the primary hint that selection is active is the reversed text and the “Ctrl+Y copy
|
||||
selection” footer text. Do we want an explicit “Selecting… (Esc to cancel)” status while a drag
|
||||
is in progress, or would that be redundant/clutter for most users?
|
||||
- Today, the primary hint that selection is active is the reversed text plus the on-screen “copy”
|
||||
affordance (`Ctrl+Shift+C`) and the footer hint. Do we want an explicit “Selecting… (Esc to
|
||||
cancel)” status while a drag is in progress, or would that be redundant/clutter for most users?
|
||||
|
||||
- **Suspend banners in scrollback.**
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ 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::TranscriptCopyUi;
|
||||
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
||||
use crate::transcript_selection::TranscriptSelection;
|
||||
use crate::transcript_selection::TranscriptSelectionPoint;
|
||||
|
|
@ -334,6 +335,7 @@ pub(crate) struct App {
|
|||
transcript_selection: TranscriptSelection,
|
||||
transcript_view_top: usize,
|
||||
transcript_total_lines: usize,
|
||||
transcript_copy_ui: TranscriptCopyUi,
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
|
|
@ -484,6 +486,8 @@ impl App {
|
|||
},
|
||||
);
|
||||
|
||||
let copy_selection_shortcut = crate::transcript_copy::detect_copy_selection_shortcut();
|
||||
|
||||
let mut app = Self {
|
||||
server: conversation_manager.clone(),
|
||||
app_event_tx,
|
||||
|
|
@ -499,6 +503,7 @@ impl App {
|
|||
transcript_selection: TranscriptSelection::default(),
|
||||
transcript_view_top: 0,
|
||||
transcript_total_lines: 0,
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
|
@ -668,6 +673,7 @@ impl App {
|
|||
transcript_scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
self.copy_selection_key(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -816,6 +822,22 @@ impl App {
|
|||
}
|
||||
|
||||
self.apply_transcript_selection(transcript_area, frame.buffer);
|
||||
if let (Some(anchor), Some(head)) = (
|
||||
self.transcript_selection.anchor,
|
||||
self.transcript_selection.head,
|
||||
) && anchor != head
|
||||
{
|
||||
self.transcript_copy_ui.render_copy_pill(
|
||||
transcript_area,
|
||||
frame.buffer,
|
||||
(anchor.line_index, anchor.column),
|
||||
(head.line_index, head.column),
|
||||
self.transcript_view_top,
|
||||
self.transcript_total_lines,
|
||||
);
|
||||
} else {
|
||||
self.transcript_copy_ui.clear_affordance();
|
||||
}
|
||||
chat_top
|
||||
}
|
||||
|
||||
|
|
@ -904,6 +926,15 @@ impl App {
|
|||
|
||||
let streaming = self.chat_widget.is_task_running();
|
||||
|
||||
if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left))
|
||||
&& self
|
||||
.transcript_copy_ui
|
||||
.hit_test(mouse_event.column, mouse_event.row)
|
||||
{
|
||||
self.copy_transcript_selection(tui);
|
||||
return;
|
||||
}
|
||||
|
||||
match mouse_event.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
let scroll_update = self.mouse_scroll_update(ScrollDirection::Up);
|
||||
|
|
@ -927,6 +958,7 @@ impl App {
|
|||
}
|
||||
MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => {}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
self.transcript_copy_ui.set_dragging(true);
|
||||
if let Some(point) = self.transcript_point_from_coordinates(
|
||||
transcript_area,
|
||||
base_x,
|
||||
|
|
@ -960,6 +992,7 @@ impl App {
|
|||
}
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
self.transcript_copy_ui.set_dragging(false);
|
||||
if self.transcript_selection.anchor == self.transcript_selection.head {
|
||||
self.transcript_selection = TranscriptSelection::default();
|
||||
tui.frame_requester().schedule_frame();
|
||||
|
|
@ -1375,6 +1408,10 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn copy_selection_key(&self) -> crate::key_hint::KeyBinding {
|
||||
self.transcript_copy_ui.key_binding()
|
||||
}
|
||||
|
||||
/// Map a mouse position in the transcript area to a content-relative
|
||||
/// selection point, if there is transcript content to select.
|
||||
fn transcript_point_from_coordinates(
|
||||
|
|
@ -1999,11 +2036,11 @@ impl App {
|
|||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('y'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
} if self.transcript_copy_ui.is_copy_key(ch, modifiers) => {
|
||||
self.copy_transcript_selection(tui);
|
||||
}
|
||||
KeyEvent {
|
||||
|
|
@ -2147,6 +2184,7 @@ mod tests {
|
|||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::new_session_info;
|
||||
use crate::transcript_copy::CopySelectionShortcut;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
|
|
@ -2188,6 +2226,9 @@ mod tests {
|
|||
transcript_selection: TranscriptSelection::default(),
|
||||
transcript_view_top: 0,
|
||||
transcript_total_lines: 0,
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
|
||||
CopySelectionShortcut::CtrlShiftC,
|
||||
),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
|
@ -2234,6 +2275,9 @@ mod tests {
|
|||
transcript_selection: TranscriptSelection::default(),
|
||||
transcript_view_top: 0,
|
||||
transcript_total_lines: 0,
|
||||
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
|
||||
CopySelectionShortcut::CtrlShiftC,
|
||||
),
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
|
@ -2487,6 +2531,184 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcript_selection_renders_copy_affordance() {
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let mut app = make_test_app().await;
|
||||
app.transcript_total_lines = 3;
|
||||
app.transcript_view_top = 0;
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 60,
|
||||
height: 3,
|
||||
};
|
||||
|
||||
app.transcript_selection = TranscriptSelection {
|
||||
anchor: Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 2,
|
||||
}),
|
||||
head: Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 6,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..area.height {
|
||||
for x in 2..area.width.saturating_sub(1) {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
|
||||
app.apply_transcript_selection(area, &mut buf);
|
||||
let anchor = app.transcript_selection.anchor.expect("anchor");
|
||||
let head = app.transcript_selection.head.expect("head");
|
||||
app.transcript_copy_ui.render_copy_pill(
|
||||
area,
|
||||
&mut buf,
|
||||
(anchor.line_index, anchor.column),
|
||||
(head.line_index, head.column),
|
||||
app.transcript_view_top,
|
||||
app.transcript_total_lines,
|
||||
);
|
||||
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
assert!(s.contains("copy"));
|
||||
assert!(s.contains("ctrl + shift + c"));
|
||||
assert!(app.transcript_copy_ui.hit_test(10, 2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcript_selection_renders_ctrl_y_copy_affordance_in_vscode_mode() {
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let mut app = make_test_app().await;
|
||||
app.transcript_copy_ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY);
|
||||
app.transcript_total_lines = 3;
|
||||
app.transcript_view_top = 0;
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 60,
|
||||
height: 3,
|
||||
};
|
||||
|
||||
app.transcript_selection = TranscriptSelection {
|
||||
anchor: Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 2,
|
||||
}),
|
||||
head: Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 6,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..area.height {
|
||||
for x in 2..area.width.saturating_sub(1) {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
|
||||
app.apply_transcript_selection(area, &mut buf);
|
||||
let anchor = app.transcript_selection.anchor.expect("anchor");
|
||||
let head = app.transcript_selection.head.expect("head");
|
||||
app.transcript_copy_ui.render_copy_pill(
|
||||
area,
|
||||
&mut buf,
|
||||
(anchor.line_index, anchor.column),
|
||||
(head.line_index, head.column),
|
||||
app.transcript_view_top,
|
||||
app.transcript_total_lines,
|
||||
);
|
||||
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
assert!(s.contains("copy"));
|
||||
assert!(s.contains("ctrl + y"));
|
||||
assert!(!s.contains("ctrl + shift + c"));
|
||||
assert!(app.transcript_copy_ui.hit_test(10, 2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transcript_selection_hides_copy_affordance_while_dragging() {
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let mut app = make_test_app().await;
|
||||
app.transcript_total_lines = 3;
|
||||
app.transcript_view_top = 0;
|
||||
app.transcript_copy_ui.set_dragging(true);
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 60,
|
||||
height: 3,
|
||||
};
|
||||
|
||||
app.transcript_selection = TranscriptSelection {
|
||||
anchor: Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 2,
|
||||
}),
|
||||
head: Some(TranscriptSelectionPoint {
|
||||
line_index: 1,
|
||||
column: 6,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..area.height {
|
||||
for x in 2..area.width.saturating_sub(1) {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
|
||||
let anchor = app.transcript_selection.anchor.expect("anchor");
|
||||
let head = app.transcript_selection.head.expect("head");
|
||||
app.transcript_copy_ui.render_copy_pill(
|
||||
area,
|
||||
&mut buf,
|
||||
(anchor.line_index, anchor.column),
|
||||
(head.line_index, head.column),
|
||||
app.transcript_view_top,
|
||||
app.transcript_total_lines,
|
||||
);
|
||||
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
assert!(!s.contains("copy"));
|
||||
assert!(!app.transcript_copy_ui.hit_test(10, 2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_session_requests_shutdown_for_previous_conversation() {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
|
@ -121,6 +123,7 @@ pub(crate) struct ChatComposer {
|
|||
transcript_scrolled: bool,
|
||||
transcript_selection_active: bool,
|
||||
transcript_scroll_position: Option<(usize, usize)>,
|
||||
transcript_copy_selection_key: KeyBinding,
|
||||
skills: Option<Vec<SkillMetadata>>,
|
||||
dismissed_skill_popup_token: Option<String>,
|
||||
}
|
||||
|
|
@ -172,6 +175,7 @@ impl ChatComposer {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
skills: None,
|
||||
dismissed_skill_popup_token: None,
|
||||
};
|
||||
|
|
@ -1540,6 +1544,7 @@ impl ChatComposer {
|
|||
transcript_scrolled: self.transcript_scrolled,
|
||||
transcript_selection_active: self.transcript_selection_active,
|
||||
transcript_scroll_position: self.transcript_scroll_position,
|
||||
transcript_copy_selection_key: self.transcript_copy_selection_key,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1571,10 +1576,12 @@ impl ChatComposer {
|
|||
scrolled: bool,
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: KeyBinding,
|
||||
) {
|
||||
self.transcript_scrolled = scrolled;
|
||||
self.transcript_selection_active = selection_active;
|
||||
self.transcript_scroll_position = scroll_position;
|
||||
self.transcript_copy_selection_key = copy_selection_key;
|
||||
}
|
||||
|
||||
fn sync_popups(&mut self) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ pub(crate) struct FooterProps {
|
|||
pub(crate) transcript_scrolled: bool,
|
||||
pub(crate) transcript_selection_active: bool,
|
||||
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
|
||||
pub(crate) transcript_copy_selection_key: KeyBinding,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
|
@ -115,7 +116,7 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
|||
}
|
||||
if props.transcript_selection_active {
|
||||
line.push_span(" · ".dim());
|
||||
line.push_span(key_hint::ctrl(KeyCode::Char('y')));
|
||||
line.push_span(props.transcript_copy_selection_key);
|
||||
line.push_span(" copy selection".dim());
|
||||
}
|
||||
vec![line]
|
||||
|
|
@ -467,6 +468,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -482,6 +484,7 @@ mod tests {
|
|||
transcript_scrolled: true,
|
||||
transcript_selection_active: true,
|
||||
transcript_scroll_position: Some((3, 42)),
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -497,6 +500,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -512,6 +516,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -527,6 +532,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -542,6 +548,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -557,6 +564,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -572,6 +580,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -587,6 +596,7 @@ mod tests {
|
|||
transcript_scrolled: false,
|
||||
transcript_selection_active: false,
|
||||
transcript_scroll_position: None,
|
||||
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -386,9 +386,14 @@ impl BottomPane {
|
|||
scrolled: bool,
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
) {
|
||||
self.composer
|
||||
.set_transcript_ui_state(scrolled, selection_active, scroll_position);
|
||||
self.composer.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3096,9 +3096,14 @@ impl ChatWidget {
|
|||
scrolled: bool,
|
||||
selection_active: bool,
|
||||
scroll_position: Option<(usize, usize)>,
|
||||
copy_selection_key: crate::key_hint::KeyBinding,
|
||||
) {
|
||||
self.bottom_pane
|
||||
.set_transcript_ui_state(scrolled, selection_active, scroll_position);
|
||||
self.bottom_pane.set_transcript_ui_state(
|
||||
scrolled,
|
||||
selection_active,
|
||||
scroll_position,
|
||||
copy_selection_key,
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward an `Op` directly to codex.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ pub(crate) const fn ctrl_alt(key: KeyCode) -> KeyBinding {
|
|||
KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
|
||||
}
|
||||
|
||||
pub(crate) const fn ctrl_shift(key: KeyCode) -> KeyBinding {
|
||||
KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::SHIFT))
|
||||
}
|
||||
|
||||
fn modifiers_to_string(modifiers: KeyModifiers) -> String {
|
||||
let mut result = String::new();
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ mod style;
|
|||
mod terminal_palette;
|
||||
mod text_formatting;
|
||||
mod tooltips;
|
||||
mod transcript_copy;
|
||||
mod transcript_selection;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
|
|
|
|||
329
codex-rs/tui2/src/transcript_copy.rs
Normal file
329
codex-rs/tui2/src/transcript_copy.rs
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
//! Transcript-selection copy UX helpers.
|
||||
//!
|
||||
//! # Background
|
||||
//!
|
||||
//! TUI2 owns a logical transcript viewport (with history that can live outside the visible buffer),
|
||||
//! plus its own selection model. Terminal-native selection/copy does not work reliably in this
|
||||
//! setup because:
|
||||
//!
|
||||
//! - The selection can extend outside the current viewport, while terminal selection can't.
|
||||
//! - We want to exclude non-content regions (like the left gutter) from copied text.
|
||||
//! - The terminal may intercept some keybindings before the app ever sees them.
|
||||
//!
|
||||
//! This module centralizes:
|
||||
//!
|
||||
//! - The effective "copy selection" shortcut (so the footer and affordance stay in sync).
|
||||
//! - Key matching for triggering copy (with terminal quirks handled in one place).
|
||||
//! - A small on-screen clickable "⧉ copy …" pill rendered near the current selection.
|
||||
//!
|
||||
//! # VS Code shortcut rationale
|
||||
//!
|
||||
//! VS Code's integrated terminal commonly captures `Ctrl+Shift+C` for its own copy behavior and
|
||||
//! does not forward the keypress to applications running inside the terminal. Since we can't
|
||||
//! observe it via crossterm, we advertise and accept `Ctrl+Y` in that environment.
|
||||
|
||||
use codex_core::terminal::TerminalName;
|
||||
use codex_core::terminal::terminal_info;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
/// The shortcut we advertise and accept for "copy selection".
|
||||
pub(crate) enum CopySelectionShortcut {
|
||||
CtrlShiftC,
|
||||
CtrlY,
|
||||
}
|
||||
|
||||
/// Returns the best shortcut to advertise/accept for "copy selection".
|
||||
///
|
||||
/// VS Code's integrated terminal typically captures `Ctrl+Shift+C` for its own copy behavior and
|
||||
/// does not forward it to applications running inside the terminal. That means we can't reliably
|
||||
/// observe it via crossterm, so we use `Ctrl+Y` there.
|
||||
///
|
||||
/// We use both the terminal name (when available) and `VSCODE_IPC_HOOK_CLI` because the terminal
|
||||
/// name can be `Unknown` early during startup in some environments.
|
||||
pub(crate) fn detect_copy_selection_shortcut() -> CopySelectionShortcut {
|
||||
let info = terminal_info();
|
||||
if info.name == TerminalName::VsCode || std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some() {
|
||||
return CopySelectionShortcut::CtrlY;
|
||||
}
|
||||
CopySelectionShortcut::CtrlShiftC
|
||||
}
|
||||
|
||||
pub(crate) fn key_binding_for(shortcut: CopySelectionShortcut) -> KeyBinding {
|
||||
match shortcut {
|
||||
CopySelectionShortcut::CtrlShiftC => key_hint::ctrl_shift(KeyCode::Char('c')),
|
||||
CopySelectionShortcut::CtrlY => key_hint::ctrl(KeyCode::Char('y')),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the given `(ch, modifiers)` should trigger "copy selection".
|
||||
///
|
||||
/// Terminal/event edge cases:
|
||||
/// - Some terminals report `Ctrl+Shift+C` as `Char('C')` with `CONTROL` only, baking the shift into
|
||||
/// the character. We accept both `c` and `C` in `CtrlShiftC` mode (including VS Code).
|
||||
/// - Some environments intercept `Ctrl+Shift+C` before the app sees it. We keep `Ctrl+Y` as a
|
||||
/// fallback in `CtrlShiftC` mode to preserve a working key path.
|
||||
pub(crate) fn is_copy_selection_key(
|
||||
shortcut: CopySelectionShortcut,
|
||||
ch: char,
|
||||
modifiers: KeyModifiers,
|
||||
) -> bool {
|
||||
if !modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match shortcut {
|
||||
CopySelectionShortcut::CtrlY => ch == 'y' && modifiers == KeyModifiers::CONTROL,
|
||||
CopySelectionShortcut::CtrlShiftC => {
|
||||
(matches!(ch, 'c' | 'C') && (modifiers.contains(KeyModifiers::SHIFT) || ch == 'C'))
|
||||
// Fallback for environments that intercept Ctrl+Shift+C.
|
||||
|| (ch == 'y' && modifiers == KeyModifiers::CONTROL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UI state for the on-screen copy affordance shown near an active selection.
|
||||
///
|
||||
/// This tracks a `Rect` for hit-testing so we can treat the pill as a clickable button.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TranscriptCopyUi {
|
||||
shortcut: CopySelectionShortcut,
|
||||
dragging: bool,
|
||||
affordance_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
impl TranscriptCopyUi {
|
||||
/// Creates a new instance using the provided shortcut.
|
||||
pub(crate) fn new_with_shortcut(shortcut: CopySelectionShortcut) -> Self {
|
||||
Self {
|
||||
shortcut,
|
||||
dragging: false,
|
||||
affordance_rect: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key_binding(&self) -> KeyBinding {
|
||||
key_binding_for(self.shortcut)
|
||||
}
|
||||
|
||||
pub(crate) fn is_copy_key(&self, ch: char, modifiers: KeyModifiers) -> bool {
|
||||
is_copy_selection_key(self.shortcut, ch, modifiers)
|
||||
}
|
||||
|
||||
pub(crate) fn set_dragging(&mut self, dragging: bool) {
|
||||
self.dragging = dragging;
|
||||
}
|
||||
|
||||
pub(crate) fn clear_affordance(&mut self) {
|
||||
self.affordance_rect = None;
|
||||
}
|
||||
|
||||
/// Returns `true` if the last rendered pill contains `(x, y)`.
|
||||
///
|
||||
/// `render_copy_pill()` sets `affordance_rect` and `clear_affordance()` clears it, so callers
|
||||
/// should treat this as "hit test against the current frame's affordance".
|
||||
pub(crate) fn hit_test(&self, x: u16, y: u16) -> bool {
|
||||
self.affordance_rect
|
||||
.is_some_and(|r| x >= r.x && x < r.right() && y >= r.y && y < r.bottom())
|
||||
}
|
||||
|
||||
/// Render the copy "pill" just below the visible end of the selection.
|
||||
///
|
||||
/// Inputs are expressed in logical transcript coordinates:
|
||||
/// - `anchor`/`head`: `(line_index, column)` in the wrapped transcript (not screen rows).
|
||||
/// - `view_top`: first logical line index currently visible in `area`.
|
||||
/// - `total_lines`: total number of logical transcript lines.
|
||||
///
|
||||
/// Placement details / edge cases:
|
||||
/// - We hide the pill while dragging to avoid accidental clicks during selection updates.
|
||||
/// - We only render if some part of the selection is visible, and there's room for a line
|
||||
/// below it inside `area`.
|
||||
/// - We scan the buffer to find the last non-space cell on each candidate row so the pill can
|
||||
/// sit "near content", not far to the right past trailing whitespace.
|
||||
///
|
||||
/// Important: this assumes the transcript content has already been rendered into `buf` for the
|
||||
/// current frame, since the placement logic derives `text_end` by inspecting buffer contents.
|
||||
pub(crate) fn render_copy_pill(
|
||||
&mut self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
anchor: (usize, u16),
|
||||
head: (usize, u16),
|
||||
view_top: usize,
|
||||
total_lines: usize,
|
||||
) {
|
||||
// Reset every frame. If we don't render (e.g. selection is off-screen) we shouldn't keep
|
||||
// an old hit target around.
|
||||
self.affordance_rect = None;
|
||||
|
||||
if self.dragging || total_lines == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip the transcript gutter (line numbers, diff markers, etc.). Selection/copy operates on
|
||||
// transcript content only.
|
||||
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
|
||||
let max_x = area.right().saturating_sub(1);
|
||||
if base_x > max_x {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize to a start/end pair so the rest of the code can assume forward order.
|
||||
let mut start = anchor;
|
||||
let mut end = head;
|
||||
if (end.0 < start.0) || (end.0 == start.0 && end.1 < start.1) {
|
||||
std::mem::swap(&mut start, &mut end);
|
||||
}
|
||||
|
||||
// We want to place the pill *near the visible end of the selection*, which means:
|
||||
// - Find the last visible transcript line that intersects the selection.
|
||||
// - Find the rightmost selected column on that line (clamped to actual rendered text).
|
||||
// - Place the pill one row below that point.
|
||||
let visible_start = view_top;
|
||||
let visible_end = view_top
|
||||
.saturating_add(area.height as usize)
|
||||
.min(total_lines);
|
||||
let mut last_visible_segment: Option<(u16, u16)> = None;
|
||||
|
||||
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
|
||||
// Skip lines outside the selection range.
|
||||
if line_index < start.0 || line_index > end.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let y = area.y + row_index as u16;
|
||||
|
||||
// Look for the rightmost non-space cell on this row so we can clamp the pill placement
|
||||
// to real content. (The transcript renderer often pads the row with spaces.)
|
||||
let mut last_text_x = None;
|
||||
for x in base_x..=max_x {
|
||||
let cell = &buf[(x, y)];
|
||||
if cell.symbol() != " " {
|
||||
last_text_x = Some(x);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(text_end) = last_text_x else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let line_end_col = if line_index == end.0 {
|
||||
end.1
|
||||
} else {
|
||||
// For multi-line selections, treat intermediate lines as selected "to the end" so
|
||||
// the pill doesn't jump left unexpectedly when only the final line has an explicit
|
||||
// end column.
|
||||
max_x.saturating_sub(base_x)
|
||||
};
|
||||
|
||||
let row_sel_end = base_x.saturating_add(line_end_col).min(max_x);
|
||||
if row_sel_end < base_x {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clamp the selection end to `text_end` so we don't place the pill far to the right on
|
||||
// lines that are mostly blank (or padded).
|
||||
let to_x = row_sel_end.min(text_end);
|
||||
last_visible_segment = Some((y, to_x));
|
||||
}
|
||||
|
||||
// If nothing in the selection is visible, don't show the affordance.
|
||||
let Some((y, to_x)) = last_visible_segment else {
|
||||
return;
|
||||
};
|
||||
// Place the pill on the row below the last visible selection segment.
|
||||
let Some(y) = y.checked_add(1).filter(|y| *y < area.bottom()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key_label: Span<'static> = self.key_binding().into();
|
||||
let key_label = key_label.content.as_ref().to_string();
|
||||
|
||||
let pill_text = format!(" ⧉ copy {key_label} ");
|
||||
let pill_width = UnicodeWidthStr::width(pill_text.as_str());
|
||||
if pill_width == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let pill_width = (pill_width as u16).min(area.width);
|
||||
// Prefer a small gap between the selected content and the pill so we don't visually merge
|
||||
// into the highlighted selection block.
|
||||
let desired_x = to_x.saturating_add(2);
|
||||
let max_start_x = area.right().saturating_sub(pill_width);
|
||||
let x = if max_start_x < area.x {
|
||||
area.x
|
||||
} else {
|
||||
desired_x.clamp(area.x, max_start_x)
|
||||
};
|
||||
|
||||
let pill_area = Rect::new(x, y, pill_width, 1);
|
||||
let base_style = Style::new().bg(Color::DarkGray);
|
||||
let icon_style = base_style.fg(Color::Cyan);
|
||||
let bold_style = base_style.add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(" ", base_style),
|
||||
Span::styled("⧉", icon_style),
|
||||
Span::styled(" ", base_style),
|
||||
Span::styled("copy", bold_style),
|
||||
Span::styled(" ", base_style),
|
||||
Span::styled(key_label, base_style),
|
||||
];
|
||||
spans.push(Span::styled(" ", base_style));
|
||||
|
||||
Paragraph::new(vec![Line::from(spans)]).render_ref(pill_area, buf);
|
||||
self.affordance_rect = Some(pill_area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::buffer::Buffer;
|
||||
|
||||
fn buf_to_string(buf: &Buffer, area: Rect) -> String {
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_y_pill_does_not_include_ctrl_shift_c() {
|
||||
let area = Rect::new(0, 0, 60, 3);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..area.height {
|
||||
for x in 2..area.width.saturating_sub(1) {
|
||||
buf[(x, y)].set_symbol("X");
|
||||
}
|
||||
}
|
||||
|
||||
let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY);
|
||||
ui.render_copy_pill(area, &mut buf, (1, 2), (1, 6), 0, 3);
|
||||
|
||||
let rendered = buf_to_string(&buf, area);
|
||||
assert!(rendered.contains("copy"));
|
||||
assert!(rendered.contains("ctrl + y"));
|
||||
assert!(!rendered.contains("ctrl + shift + c"));
|
||||
assert!(ui.affordance_rect.is_some());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue