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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user