feat(tui): pager copy-out via c / y key (#1354)

The pager intercepts mouse capture, so terminal-native selection is
disabled inside it. Until now there was no in-app way to copy the
content the user came specifically to see — high-frustration UX gap
for the Alt+V (tool details), Ctrl+O (thinking), shell-job, task,
MCP-manager, and selection pagers.

Both `c` (clipboard convention) and `y` (vim-yank convention) now
emit a `ViewEvent::CopyToClipboard` carrying the full pager body.
The host dispatcher in `ui.rs` writes through `app.clipboard` and
toasts a status confirmation ("Pager content copied" /
"Copy failed"). Empty-body pagers report the empty state instead of
silently no-op'ing.

Footer hint updated to surface the new keys:
  j/k scroll  Space page  Ctrl+D/U half  g/G top/bottom  / search  c copy  q/Esc close

Mouse selection inside the pager remains intercepted (the alternative
— releasing capture inside the modal — would break vim-style
navigation), so this is the supported copy path.

Closes #1354.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-10 09:45:37 -05:00
parent 328e3bd191
commit aea6bb5f46
4 changed files with 106 additions and 3 deletions
+11
View File
@@ -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.
+78 -3
View File
@@ -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);
+9
View File
@@ -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,
+8
View File
@@ -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)]