fix: copy retained mouse selections with keyboard shortcuts

refs #1782
This commit is contained in:
Ogulcan Celik 2026-07-25 02:40:27 +03:00
parent 0065a0ef34
commit b33e5f973f
11 changed files with 345 additions and 57 deletions

View File

@ -6,6 +6,7 @@
- Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0.
### Fixed
- `ui.copy_on_select = false` now retains drag and double-click word selections without copying; `Ctrl+C`, or `Cmd+C` when the host terminal forwards it, copies and clears the selection.
- Pane and agent read responses now report `truncated: true` when older terminal rows were omitted. (#1717)
- Pane applications that query OSC 4 palette colors now inherit the host terminal palette. (#1752)
- Ctrl-clicking a pane URL no longer forwards an unmatched mouse release to alternate-screen applications, preventing duplicate browser tabs. (#1761)

View File

@ -597,7 +597,7 @@
"key": "ui.copy_on_select",
"type": "boolean",
"default": "true",
"description": "Copy text selected with the mouse."
"description": "Automatically copy text selected by mouse drag or double-click. When disabled, Ctrl+C or a host-forwarded Cmd+C copies and clears the retained selection."
},
{
"key": "ui.host_cursor",

View File

@ -2063,7 +2063,7 @@ impl AppState {
self.selection_autoscroll = None;
}
pub(crate) fn copy_word_at_pane_cell(
pub(crate) fn select_word_at_pane_cell(
&mut self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
@ -2113,23 +2113,30 @@ impl AppState {
return false;
};
// Copy the token and keep its selection visible as short-lived feedback.
let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics);
if !selection.finish() {
return false;
}
let Some(text) = rt
.extract_selection(&selection)
.filter(|text| !text.is_empty())
else {
self.clear_selection();
return false;
let text = if self.copy_on_select {
let Some(text) = rt
.extract_selection(&selection)
.filter(|text| !text.is_empty())
else {
self.clear_selection();
return false;
};
Some(text)
} else {
None
};
self.request_clipboard_write = Some(text.into_bytes());
self.selection = Some(selection);
self.selection_autoscroll = None;
info!("copied double-clicked token to clipboard");
if let Some(text) = text {
self.request_clipboard_write = Some(text.into_bytes());
info!("copied double-clicked token to clipboard");
}
true
}
@ -2184,7 +2191,7 @@ impl AppState {
Some(sel) => sel,
None => return,
};
if !sel.finish() {
if !sel.is_finalized() && !sel.finish() {
return;
}

269
src/app/input/clipboard.rs Normal file
View File

@ -0,0 +1,269 @@
use crossterm::event::{KeyCode, KeyModifiers};
use crate::{
app::{App, InputSourceId},
input::TerminalKey,
};
fn is_retained_selection_copy_key(key: TerminalKey) -> bool {
matches!(key.code, KeyCode::Char('c' | 'C'))
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
}
impl App {
pub(super) fn dispatch_pending_clipboard_write(&mut self) -> bool {
let Some(content) = self.state.request_clipboard_write.take() else {
return false;
};
if self
.event_tx
.try_send(crate::events::AppEvent::ClipboardWrite { content })
.is_err()
{
tracing::warn!("failed to queue clipboard write event");
}
true
}
pub(super) fn try_copy_retained_selection(
&mut self,
source_id: InputSourceId,
key: TerminalKey,
) -> bool {
if self.state.copy_on_select
|| !is_retained_selection_copy_key(key)
|| !self
.state
.selection
.as_ref()
.is_some_and(crate::selection::Selection::is_finalized)
{
return false;
}
self.state.copy_selection(&self.terminal_runtimes);
if !self.dispatch_pending_clipboard_write() {
return false;
}
self.suppressed_repeat_keys.insert((source_id, key.code));
true
}
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
use super::super::{app_for_mouse_test, mouse};
use super::*;
use crate::{app::Mode, events::AppEvent, workspace::Workspace};
fn app_with_screen_bytes_and_input(
bytes: &[u8],
) -> (
App,
crate::layout::PaneInfo,
tokio::sync::mpsc::Receiver<Bytes>,
) {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
let (runtime, input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
0,
bytes,
4,
);
ws.insert_test_runtime(pane_id, runtime);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
(app, info, input_rx)
}
fn drag_select_range(
app: &mut App,
info: &crate::layout::PaneInfo,
start_col: u16,
end_col: u16,
) {
let row = info.inner_rect.y;
let start_col = info.inner_rect.x + start_col;
let end_col = info.inner_rect.x + end_col;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
start_col,
row,
));
app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
}
fn clipboard_write_content(app: &mut App) -> Vec<u8> {
match app.event_rx.try_recv().expect("clipboard write event") {
AppEvent::ClipboardWrite { content } => content,
event => panic!("unexpected event: {event:?}"),
}
}
fn assert_visible_selection(app: &App) {
assert!(app
.state
.selection
.as_ref()
.is_some_and(crate::selection::Selection::is_visible));
}
#[tokio::test]
async fn copy_on_select_disabled_ctrl_c_copies_and_clears_retained_selection() {
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
app.state.copy_on_select = false;
drag_select_range(&mut app, &info, 0, 4);
assert_visible_selection(&app);
assert!(app.event_rx.try_recv().is_err());
let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
let source_id = 41;
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(ctrl_c)],
false,
);
let content = clipboard_write_content(&mut app);
assert_eq!(content, b"alpha");
assert!(app.state.selection.is_none());
assert!(input_rx.try_recv().is_err());
app.handle_internal_event(AppEvent::ClipboardWrite { content });
assert_eq!(
app.state
.copy_feedback
.as_ref()
.map(|feedback| feedback.message.as_str()),
Some("copied to clipboard")
);
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(
ctrl_c.with_kind(KeyEventKind::Repeat),
)],
false,
);
assert!(app.event_rx.try_recv().is_err());
assert!(input_rx.try_recv().is_err());
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(
ctrl_c.with_kind(KeyEventKind::Release),
)],
false,
);
app.route_client_events_from(
source_id,
vec![crate::raw_input::RawInputEvent::Key(ctrl_c)],
false,
);
assert_eq!(
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
b"\x03"
);
assert!(app.event_rx.try_recv().is_err());
}
#[tokio::test]
async fn copy_on_select_disabled_cmd_c_copies_retained_selection() {
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
app.state.copy_on_select = false;
drag_select_range(&mut app, &info, 0, 4);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Char('c'), KeyModifiers::SUPER));
assert_eq!(clipboard_write_content(&mut app), b"alpha");
assert!(app.state.selection.is_none());
assert!(input_rx.try_recv().is_err());
}
#[tokio::test]
async fn retained_selection_copy_shortcut_is_disabled_with_copy_on_select() {
let (mut app, _info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let mut selection = crate::selection::Selection::range(
pane_id,
0,
0,
4,
app.state
.pane_scroll_metrics(&app.terminal_runtimes, pane_id),
);
assert!(selection.finish());
app.state.selection = Some(selection);
app.state.copy_on_select = true;
app.handle_terminal_key_headless(TerminalKey::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
));
assert!(app.state.selection.is_none());
assert!(app.event_rx.try_recv().is_err());
assert_eq!(
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
b"\x03"
);
}
#[tokio::test]
async fn retained_selection_copy_shortcut_forwards_when_selection_text_is_empty() {
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"");
app.state.copy_on_select = false;
drag_select_range(&mut app, &info, 0, 4);
assert_visible_selection(&app);
app.handle_terminal_key_headless(TerminalKey::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
));
assert!(app.state.selection.is_none());
assert!(app.event_rx.try_recv().is_err());
assert_eq!(
input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(),
b"\x03"
);
}
#[tokio::test]
async fn retained_selection_copy_shortcut_requires_exact_modifiers() {
let (mut app, info, mut input_rx) = app_with_screen_bytes_and_input(b"alpha beta");
app.state.copy_on_select = false;
drag_select_range(&mut app, &info, 0, 4);
app.handle_terminal_key_headless(TerminalKey::new(
KeyCode::Char('C'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert!(app.state.selection.is_none());
assert!(app.event_rx.try_recv().is_err());
assert_eq!(
input_rx
.try_recv()
.expect("forwarded Ctrl-Shift-C")
.as_ref(),
b"\x03"
);
}
}

View File

@ -22,15 +22,7 @@ impl App {
}
self.state
.handle_copy_mode_key(&self.terminal_runtimes, key);
if let Some(content) = self.state.request_clipboard_write.take() {
if self
.event_tx
.try_send(crate::events::AppEvent::ClipboardWrite { content })
.is_err()
{
tracing::warn!("failed to queue clipboard write event");
}
}
self.dispatch_pending_clipboard_write();
}
}

View File

@ -36,6 +36,7 @@ fn modified_url_click_modifier_matches_terminal_mouse_reporting() {
assert_eq!(modified_url_click_modifier(), KeyModifiers::CONTROL);
}
mod clipboard;
mod copy_mode;
mod modal;
mod mouse;
@ -401,15 +402,7 @@ impl App {
self.save_agent_panel_sort(self.state.agent_panel_sort);
}
if let Some(content) = self.state.request_clipboard_write.take() {
if self
.event_tx
.try_send(crate::events::AppEvent::ClipboardWrite { content })
.is_err()
{
tracing::warn!("failed to queue clipboard write event");
}
}
self.dispatch_pending_clipboard_write();
// Sync autoscroll deadline with state (mouse handler may have
// set or cleared selection_autoscroll during handle_mouse).
@ -569,14 +562,12 @@ impl App {
};
// Require the second click to land near the first click in the same pane
// and within the double-click window so adjacent interactions do not copy.
// and within the double-click window so adjacent interactions do not select a word.
if !self.take_pane_double_click(click) {
return false;
}
// Preserve a short highlight after copying so the user gets visible
// confirmation without leaving a persistent selection behind.
self.copy_double_clicked_word(click)
self.select_double_clicked_word(click)
}
fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option<PaneClickState> {
@ -620,18 +611,20 @@ impl App {
true
}
fn copy_double_clicked_word(&mut self, click: PaneClickState) -> bool {
let copied = self.state.copy_word_at_pane_cell(
fn select_double_clicked_word(&mut self, click: PaneClickState) -> bool {
let selected = self.state.select_word_at_pane_cell(
&self.terminal_runtimes,
click.pane_id,
click.viewport_row,
click.col,
);
if copied {
self.selection_highlight_clear_deadline =
Some(std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION);
if selected {
self.selection_highlight_clear_deadline = self
.state
.copy_on_select
.then(|| std::time::Instant::now() + super::PANE_COPY_HIGHLIGHT_DURATION);
}
copied
selected
}
}

View File

@ -796,7 +796,7 @@ impl AppState {
MouseEventKind::Up(MouseButton::Left) => {
// Mouse-up either finishes a drag selection or releases after a
// double-click copy; the latter is already finalized.
// double-click word selection; the latter is already finalized.
if let Some(selection) = self.selection.as_ref() {
let was_click = selection.was_just_click();
let was_finalized = selection.is_finalized();
@ -808,7 +808,7 @@ impl AppState {
if was_click {
self.selection = None;
} else if was_finalized {
// Double-click copy already finalized this selection.
// Double-click already finalized this word selection.
} else if self.copy_on_select {
self.copy_selection(terminal_runtimes);
} else if let Some(selection) = self.selection.as_mut() {

View File

@ -3,7 +3,7 @@ use crossterm::event::KeyCode;
use tracing::{debug, warn};
use crate::{
app::{App, Mode, TerminalInputTarget},
app::{App, InputSourceId, Mode, TerminalInputTarget},
input::TerminalKey,
};
@ -28,9 +28,18 @@ fn is_modifier_only_key(code: &KeyCode) -> bool {
}
impl App {
#[cfg(test)]
pub(crate) fn handle_terminal_key_headless(
&mut self,
key: TerminalKey,
) -> Option<TerminalInputTarget> {
self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key)
}
pub(crate) fn handle_terminal_key_headless_from(
&mut self,
source_id: InputSourceId,
key: TerminalKey,
) -> Option<TerminalInputTarget> {
match self.prepare_popup_key_forward(key) {
PreparedPopupInput::NotOpen => {}
@ -44,20 +53,27 @@ impl App {
}
}
let input = self.prepare_terminal_key_forward(key)?;
let input = self.prepare_terminal_key_forward(source_id, key)?;
let sent = self
.lookup_runtime_sender(input.ws_idx, input.pane_id)
.is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok());
sent.then_some(input.target)
}
fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> {
fn prepare_terminal_key_forward(
&mut self,
source_id: InputSourceId,
key: TerminalKey,
) -> Option<PreparedPaneInput> {
let key_event = key.as_key_event();
if self.try_copy_retained_selection(source_id, key) {
return None;
}
self.state.clear_selection();
self.selection_autoscroll_deadline = None;
self.state.update_dismissed = true;
let key_event = key.as_key_event();
if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key)
{
debug!(
@ -365,7 +381,7 @@ impl App {
}
}
let input = self.prepare_terminal_key_forward(key)?;
let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?;
let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
runtime.send_bytes(input.bytes).await.is_ok()
} else {
@ -714,7 +730,7 @@ mod tests {
}
#[tokio::test]
async fn copy_on_select_disabled_keeps_explicit_double_click_copy() {
async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() {
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
app.state.copy_on_select = false;
let col = info.inner_rect.x + 2;
@ -722,16 +738,22 @@ mod tests {
double_click(&mut app, col, row);
assert_eq!(clipboard_write_content(&mut app), b"alpha");
assert_visible_selection(&app);
assert!(app.selection_highlight_clear_deadline.is_some());
assert!(app.selection_highlight_clear_deadline.is_none());
assert!(app.event_rx.try_recv().is_err());
app.handle_terminal_key_headless(TerminalKey::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
));
assert_eq!(clipboard_write_content(&mut app), b"alpha");
assert!(app.state.selection.is_none());
}
#[tokio::test]
async fn new_drag_cancels_stale_double_click_highlight_deadline() {
let (mut app, info) = app_with_screen_bytes(b"alpha beta");
app.state.copy_on_select = false;
let row = info.inner_rect.y;
let word_col = info.inner_rect.x + 2;
@ -740,6 +762,7 @@ mod tests {
let stale_deadline = app
.selection_highlight_clear_deadline
.expect("double-click highlight deadline");
app.state.copy_on_select = false;
let start_col = info.inner_rect.x + 6;
let end_col = info.inner_rect.x + 9;

View File

@ -1621,7 +1621,9 @@ impl App {
if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal
{
self.suppressed_repeat_keys.remove(&pressed_key_id);
if let Some(target) = self.handle_terminal_key_headless(key) {
if let Some(target) =
self.handle_terminal_key_headless_from(source_id, key)
{
if !key.is_text_commit {
self.pressed_terminal_keys.insert(
pressed_key_id,
@ -1650,7 +1652,7 @@ impl App {
|| self.state.mode == Mode::Terminal)
&& !self.suppressed_repeat_keys.contains(&pressed_key_id)
{
let _ = self.handle_terminal_key_headless(key);
let _ = self.handle_terminal_key_headless_from(source_id, key);
}
}
crossterm::event::KeyEventKind::Release => {

View File

@ -269,8 +269,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Pane apps like lazygit and btop can still receive mouse when they request it.
# mouse_capture = true
# Automatically copy text selected by mouse drag.
# Set false to keep drag selection visible without copying; double-click still copies a word.
# Automatically copy text selected with the mouse.
# Set false to retain drag or double-click word selection until Ctrl+C,
# or Cmd+C when the host forwards it, copies and clears it.
# copy_on_select = true
# Host cursor policy: "auto", "native", or "drawn".

View File

@ -7,7 +7,7 @@
//! MouseUp → Selection finalized; optionally copied by the caller
//! Next click / key → A retained selection is cleared
//!
//! Double-click copy also briefly highlights the selected word.
//! Double-click selects a word; the caller decides whether to copy it immediately.
//!
//! Rows are stored in screen-buffer coordinates instead of viewport-relative
//! coordinates. That keeps selection stable while the pane scrolls.