diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md index d8cefedfe..57e697861 100644 --- a/codex-rs/tui2/docs/tui_viewport_and_history.md +++ b/codex-rs/tui2/docs/tui_viewport_and_history.md @@ -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.** diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index a290433e0..39d148adb 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -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, @@ -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; diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 7ca10c6d2..3d5de81a9 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -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>, dismissed_skill_popup_token: Option, } @@ -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) { diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 7f2b4e628..57bffd563 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -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> { } 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')), }, ); } diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index fbab5e14a..c6ea991c8 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -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(); } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index b128efab6..ee0dd75dd 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -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. diff --git a/codex-rs/tui2/src/key_hint.rs b/codex-rs/tui2/src/key_hint.rs index f277f0738..551725663 100644 --- a/codex-rs/tui2/src/key_hint.rs +++ b/codex-rs/tui2/src/key_hint.rs @@ -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) { diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 0e3c805fb..e006ed8a7 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -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; diff --git a/codex-rs/tui2/src/transcript_copy.rs b/codex-rs/tui2/src/transcript_copy.rs new file mode 100644 index 000000000..b7242ee29 --- /dev/null +++ b/codex-rs/tui2/src/transcript_copy.rs @@ -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, +} + +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> = 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()); + } +}