diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b881683..93725785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,17 @@ internal fix. Big thanks to every contributor below. common failure pattern: DNS / connection refused / TLS / 4xx / 429 / timeout. Each hint points at the most likely cause and a concrete next step. + +### Added + +- **Pager copy-out** (#1354) — full-screen pagers (`Alt+V` tool details, + `Ctrl+O` thinking content, shell-job / task / MCP-manager pagers, and + the selection pager) now accept `c` or `y` to copy the entire body to + the system clipboard. The pager intercepts mouse capture so terminal- + native selection isn't available inside it; this restores the + copy-out path that users on macOS / Windows / WSL expect. The footer + hint now reads `… / search c copy q/Esc close`. A status toast + confirms success ("Pager content copied"), empty-body, or failure. - **HTTP 400 quota errors retried** (#1203) — some OpenAI-compatible gateways return quota/rate-limit errors as HTTP 400 instead of 429. These are now classified as retryable `RateLimited` errors. diff --git a/crates/tui/src/tui/pager.rs b/crates/tui/src/tui/pager.rs index 6d77cdaa..8b3a72a5 100644 --- a/crates/tui/src/tui/pager.rs +++ b/crates/tui/src/tui/pager.rs @@ -10,6 +10,7 @@ //! - `Ctrl+F` / PageDown / Space — full page down //! - `Ctrl+B` / PageUp / Shift+Space — full page up //! - `/` — start search; `n` / `N` — next / previous match +//! - `c` / `y` — copy the entire pager body to the system clipboard //! - `q` / Esc — close pager use std::cell::Cell; @@ -25,11 +26,12 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::palette; -use crate::tui::views::{ModalKind, ModalView, ViewAction}; +use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; /// Footer hint shown along the bottom border of the pager. Kept short so it /// fits on narrow terminals; full reference lives in the module docs. -const FOOTER_HINT_NAV: &str = " j/k scroll Space page Ctrl+D/U half g/G top/bottom / search"; +const FOOTER_HINT_NAV: &str = + " j/k scroll Space page Ctrl+D/U half g/G top/bottom / search c copy"; const FOOTER_HINT_EXIT: &str = " q/Esc close "; pub struct PagerView { @@ -94,6 +96,16 @@ impl PagerView { self.scroll = max_scroll; } + /// Plain-text body of the pager joined with `\n`, suitable for sending + /// to the system clipboard via `ViewEvent::CopyToClipboard`. Reflects the + /// content the user sees, including any width-based wrapping that + /// `from_text` introduced — copying the visible text is the expected + /// affordance when the user can't reach terminal-native selection inside + /// the modal (#1354). + pub fn body_text(&self) -> String { + self.plain_lines.join("\n") + } + /// Return the page height (in lines) used for paging keys. /// /// Falls back to a small constant (10) before the first render so the @@ -321,6 +333,20 @@ impl ModalView for PagerView { self.pending_g = false; ViewAction::None } + // Copy the entire pager body to the clipboard. The pager + // intercepts mouse capture so terminal-native selection is + // disabled inside it; without this binding users with no + // out-of-band copy path would have no way to extract content + // they can see (#1354). Both `c` and `y` are wired so users + // landing from either OS-clipboard or vim convention find a + // working key. + KeyCode::Char('c') | KeyCode::Char('y') => { + self.pending_g = false; + ViewAction::Emit(ViewEvent::CopyToClipboard { + text: self.body_text(), + label: "Pager content".to_string(), + }) + } _ => ViewAction::None, } } @@ -663,7 +689,15 @@ mod tests { fn footer_hint_includes_new_bindings() { // The rendered pager must surface the new vim-style bindings to // the user; check the footer hint covers the headline keys. - for needle in &["j/k", "g/G", "Space", "Ctrl+D", "/ search", "q/Esc close"] { + for needle in &[ + "j/k", + "g/G", + "Space", + "Ctrl+D", + "/ search", + "c copy", + "q/Esc close", + ] { let full_hint = format!("{FOOTER_HINT_EXIT}{FOOTER_HINT_NAV}"); assert!( full_hint.contains(needle), @@ -672,6 +706,47 @@ mod tests { } } + #[test] + fn c_emits_copy_event_with_full_body() { + // #1354: the pager intercepts mouse capture, so users have no way to + // copy content out without an in-app key. Both `c` and `y` should + // emit a CopyToClipboard event carrying the whole body so the host + // dispatcher (in ui.rs) can write through `app.clipboard` and toast + // a confirmation. + let mut p = make_pager(3); + let action = p.handle_key(key(KeyCode::Char('c'))); + match action { + ViewAction::Emit(ViewEvent::CopyToClipboard { text, label }) => { + assert_eq!(text, "line-000\nline-001\nline-002"); + assert_eq!(label, "Pager content"); + } + other => panic!("expected CopyToClipboard emit, got {other:?}"), + } + } + + #[test] + fn y_emits_copy_event_for_vim_users() { + let mut p = make_pager(3); + let action = p.handle_key(key(KeyCode::Char('y'))); + assert!( + matches!(action, ViewAction::Emit(ViewEvent::CopyToClipboard { .. })), + "y must emit a copy event for vim-yank parity" + ); + } + + #[test] + fn copy_keys_inert_in_search_mode() { + // Within `/`-search mode `c` and `y` must be treated as search + // characters, not as a copy trigger — otherwise users typing a + // query that contains either letter would lose their input. + let mut p = make_pager(10); + let _ = p.handle_key(key(KeyCode::Char('/'))); + assert!(p.search_mode); + let action = p.handle_key(key(KeyCode::Char('c'))); + assert!(matches!(action, ViewAction::None)); + assert_eq!(p.search_input, "c"); + } + #[test] fn footer_hint_is_rendered_in_buffer() { let p = make_pager(5); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 44e7682f..12ef103b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5776,6 +5776,15 @@ async fn handle_view_events( ViewEvent::OpenTextPager { title, content } => { open_text_pager(app, title, content); } + ViewEvent::CopyToClipboard { text, label } => { + if text.is_empty() { + app.status_message = Some(format!("{label} is empty")); + } else if app.clipboard.write_text(&text).is_ok() { + app.status_message = Some(format!("{label} copied")); + } else { + app.status_message = Some(format!("Copy failed ({label})")); + } + } ViewEvent::ApprovalDecision { tool_id, tool_name, diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 7fc451eb..845572ac 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -187,6 +187,14 @@ pub enum ViewEvent { }, ShellControlBackground, ShellControlCancel, + /// Emitted by the pager (`c` / `y`) to copy its body to the system + /// clipboard. The host handler writes via `app.clipboard` and surfaces a + /// status message — modal views cannot reach `app` directly. `label` is + /// the noun shown in the success / failure status (e.g. "Pager content"). + CopyToClipboard { + text: String, + label: String, + }, } #[derive(Debug, Clone)]