aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorZack Drach <zackdrach@gmail.com>2026-05-26 03:40:29 -0700
committerGitHub <noreply@github.com>2026-05-26 13:40:29 +0300
commit6c4a947244b7be05496f9d3bd5ad2d00a69cca03 (patch)
tree421b4a009e2a201c9be65806fd0e13fe9899c73a /src
parent33e71ed2f50e5ca8950029f89f41e0ec135b87c0 (diff)
Add tmux-style double-click word copy (#296)
* feat: add tmux-style double-click copy Double-clicking terminal output now selects and copies the token under the cursor, similar to tmux. The copied token briefly stays highlighted as feedback, and token detection handles common shell output like paths, URLs, variables, CJK text, and surrounding punctuation. Mouse events are still forwarded unchanged when the child terminal has mouse reporting enabled. refs #142 * fix: tighten double-click copy refs #142 --------- Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs486
-rw-r--r--src/app/input/mod.rs121
-rw-r--r--src/app/input/mouse.rs18
-rw-r--r--src/app/input/terminal.rs156
-rw-r--r--src/app/mod.rs23
-rw-r--r--src/app/runtime.rs25
-rw-r--r--src/selection.rs24
-rw-r--r--src/server/headless.rs2
8 files changed, 827 insertions, 28 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 9132d7bd..65142fd1 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -6,8 +6,10 @@ use tracing::{info, warn};
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
+use crate::selection::Selection;
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
use crate::workspace::WorkspaceGitStatus;
+use unicode_width::UnicodeWidthChar;
use super::state::{
AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind,
@@ -1256,6 +1258,76 @@ impl AppState {
self.selection_autoscroll = None;
}
+ pub(crate) fn copy_word_at_pane_cell(
+ &mut self,
+ terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ pane_id: crate::layout::PaneId,
+ viewport_row: u16,
+ col: u16,
+ ) -> bool {
+ // Resolve the active pane cell the double-click landed on.
+ let Some(ws_idx) = self
+ .active
+ .filter(|idx| self.workspaces.get(*idx).is_some())
+ else {
+ return false;
+ };
+
+ let Some(info) = self.pane_info_by_id(pane_id) else {
+ return false;
+ };
+ if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
+ return false;
+ }
+
+ // Leave mouse input to terminal apps that requested it.
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
+ else {
+ return false;
+ };
+ if rt
+ .input_state()
+ .is_some_and(crate::pane::InputState::mouse_reporting_enabled)
+ {
+ return false;
+ }
+
+ // Read the visible row and identify the clicked token bounds.
+ let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
+ let row_selection = Selection::range(
+ pane_id,
+ viewport_row,
+ 0,
+ info.inner_rect.width.saturating_sub(1),
+ metrics,
+ );
+ let Some(row_text) = rt.extract_selection(&row_selection) else {
+ return false;
+ };
+ let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else {
+ 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;
+ };
+ self.request_clipboard_write = Some(text.into_bytes());
+ self.selection = Some(selection);
+ self.selection_autoscroll = None;
+ info!("copied double-clicked token to clipboard");
+ true
+ }
+
pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
let mut sel = match self.selection.take() {
Some(sel) => sel,
@@ -1273,7 +1345,6 @@ impl AppState {
let text = self
.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id)
.and_then(|rt| rt.extract_selection(&sel));
-
if let Some(text) = text {
if !text.is_empty() {
self.request_clipboard_write = Some(text.into_bytes());
@@ -1281,9 +1352,245 @@ impl AppState {
}
}
- self.selection = None;
- self.selection_autoscroll = None;
+ self.clear_selection();
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct TextCell {
+ ch: char,
+ start_col: u16,
+ end_col: u16,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct CellSpan {
+ start: usize,
+ end: usize,
+}
+
+impl CellSpan {
+ fn contains(self, idx: usize) -> bool {
+ idx >= self.start && idx <= self.end
+ }
+
+ fn columns(self, cells: &[TextCell]) -> (u16, u16) {
+ (cells[self.start].start_col, cells[self.end].end_col)
+ }
+}
+
+/// Finds the terminal display-column bounds for the token under a double-click.
+///
+/// The algorithm first maps text to terminal cells so wide characters and
+/// zero-width marks use display columns, then prefers structured spans that
+/// users expect to copy whole (URLs and quoted paths), and finally falls back
+/// to a separator-delimited token.
+fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
+ // Map the row into display cells before doing any word-boundary work.
+ let cells = text_cells(row);
+ let clicked_idx = cell_index_at_column(&cells, col)?;
+
+ // Prefer spans that can legally include punctuation or spaces.
+ let span = url_span_at_column(&cells, clicked_idx)
+ .or_else(|| quoted_path_span_at_column(&cells, clicked_idx))
+ .or_else(|| token_span_at_column(&cells, clicked_idx))?;
+
+ // Convert the internal cell span back to inclusive terminal columns.
+ Some(span.columns(&cells))
+}
+
+fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ if is_word_separator(cells[clicked_idx].ch) {
+ return None;
+ }
+
+ let mut start = clicked_idx;
+ while start > 0 && !is_word_separator(cells[start - 1].ch) {
+ start -= 1;
+ }
+
+ let mut end = clicked_idx;
+ while end + 1 < cells.len() && !is_word_separator(cells[end + 1].ch) {
+ end += 1;
}
+
+ trim_token_edges(cells, CellSpan { start, end }).filter(|span| span.contains(clicked_idx))
+}
+
+fn text_cells(row: &str) -> Vec<TextCell> {
+ let mut next_col = 0u16;
+ row.chars()
+ .map(|ch| {
+ let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
+ let start_col = if width == 0 {
+ next_col.saturating_sub(1)
+ } else {
+ next_col
+ };
+ if width > 0 {
+ next_col = next_col.saturating_add(width);
+ }
+ TextCell {
+ ch,
+ start_col,
+ end_col: next_col.saturating_sub(1),
+ }
+ })
+ .collect()
+}
+
+fn cell_index_at_column(cells: &[TextCell], col: u16) -> Option<usize> {
+ cells
+ .iter()
+ .position(|cell| cell.start_col <= col && col <= cell.end_col)
+}
+
+fn url_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ let mut start = 0;
+ while start < cells.len() {
+ if starts_with_chars(&cells[start..], "http://")
+ || starts_with_chars(&cells[start..], "https://")
+ {
+ let mut end = start;
+ while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() {
+ end += 1;
+ }
+ if clicked_idx >= start && clicked_idx <= end {
+ let span = trim_url_edges(cells, CellSpan { start, end })?;
+ return span.contains(clicked_idx).then_some(span);
+ }
+ start = end + 1;
+ } else {
+ start += 1;
+ }
+ }
+ None
+}
+
+fn trim_url_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
+ let start = span.start;
+ let mut end = span.end;
+ while start <= end && should_trim_trailing_url_cell(cells, start, end) {
+ if end == 0 {
+ return None;
+ }
+ end -= 1;
+ }
+ (start <= end).then_some(CellSpan { start, end })
+}
+
+fn should_trim_trailing_url_cell(cells: &[TextCell], start: usize, end: usize) -> bool {
+ match cells[end].ch {
+ '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?' => true,
+ ')' => !trailing_url_closer_is_balanced(cells, start, end, '(', ')'),
+ ']' => !trailing_url_closer_is_balanced(cells, start, end, '[', ']'),
+ '}' => !trailing_url_closer_is_balanced(cells, start, end, '{', '}'),
+ _ => false,
+ }
+}
+
+fn trailing_url_closer_is_balanced(
+ cells: &[TextCell],
+ start: usize,
+ end: usize,
+ open: char,
+ close: char,
+) -> bool {
+ let mut balance = 0i32;
+ for cell in &cells[start..end] {
+ if cell.ch == open {
+ balance += 1;
+ } else if cell.ch == close {
+ balance -= 1;
+ }
+ }
+ balance > 0
+}
+
+fn quoted_path_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ let clicked = cells.get(clicked_idx)?.ch;
+ if clicked == '"' || clicked == '\'' || clicked == '`' {
+ return None;
+ }
+
+ for quote in ['"', '\'', '`'] {
+ let mut start = None;
+ for (idx, cell) in cells.iter().copied().enumerate() {
+ let ch = cell.ch;
+ if ch != quote || is_escaped(cells, idx) {
+ continue;
+ }
+ if let Some(open) = start {
+ if clicked_idx > open
+ && clicked_idx < idx
+ && cells[open + 1..idx].iter().any(|cell| cell.ch == '/')
+ {
+ return Some(CellSpan {
+ start: open + 1,
+ end: idx - 1,
+ });
+ }
+ start = None;
+ } else {
+ start = Some(idx);
+ }
+ }
+ }
+ None
+}
+
+fn is_escaped(cells: &[TextCell], idx: usize) -> bool {
+ let mut slashes = 0;
+ let mut cursor = idx;
+ while cursor > 0 && cells[cursor - 1].ch == '\\' {
+ slashes += 1;
+ cursor -= 1;
+ }
+ slashes % 2 == 1
+}
+
+fn starts_with_chars(cells: &[TextCell], prefix: &str) -> bool {
+ prefix
+ .chars()
+ .enumerate()
+ .all(|(idx, expected)| cells.get(idx).is_some_and(|cell| cell.ch == expected))
+}
+
+fn is_word_separator(ch: char) -> bool {
+ ch.is_whitespace()
+ || matches!(
+ ch,
+ '|' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!'
+ )
+}
+
+fn trim_token_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
+ let mut start = span.start;
+ let mut end = span.end;
+ while start <= end && is_leading_token_wrapper(cells[start].ch) {
+ start += 1;
+ }
+ if start < end && cells[end].ch == '$' && is_trailing_token_wrapper(cells[end - 1].ch) {
+ end -= 1;
+ }
+ while start <= end && is_trailing_token_wrapper(cells[end].ch) {
+ if end == 0 {
+ return None;
+ }
+ end -= 1;
+ }
+ (start <= end).then_some(CellSpan { start, end })
+}
+
+fn is_leading_token_wrapper(ch: char) -> bool {
+ matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')
+}
+
+fn is_trailing_token_wrapper(ch: char) -> bool {
+ matches!(
+ ch,
+ ')' | ']' | '}' | '>' | '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?'
+ )
}
// ---------------------------------------------------------------------------
@@ -1625,6 +1932,179 @@ mod tests {
});
}
+ fn selected_word(row: &str, col: u16) -> Option<String> {
+ let (start, end) = word_bounds_at_column(row, col)?;
+ Some(text_in_cell_range(row, start, end))
+ }
+
+ fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String {
+ text_cells(row)
+ .into_iter()
+ .filter(|cell| cell.start_col >= start_col && cell.end_col <= end_col)
+ .map(|cell| cell.ch)
+ .collect()
+ }
+
+ fn col_of(row: &str, needle: &str) -> u16 {
+ let byte_idx = row
+ .find(needle)
+ .unwrap_or_else(|| panic!("{needle:?} not found in {row:?}"));
+ let prefix = &row[..byte_idx];
+ prefix
+ .chars()
+ .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
+ .sum()
+ }
+
+ fn assert_selects(row: &str, click: &str, expected: &str) {
+ assert_eq!(
+ selected_word(row, col_of(row, click)).as_deref(),
+ Some(expected),
+ "row={row:?}, click={click:?}"
+ );
+ }
+
+ fn assert_selects_nothing(row: &str, click: &str) {
+ assert_eq!(
+ selected_word(row, col_of(row, click)),
+ None,
+ "row={row:?}, click={click:?}"
+ );
+ }
+
+ #[test]
+ fn double_click_word_bounds_cover_terminal_text() {
+ let cases = [
+ (
+ "see https://example.com/a-b_c?q=x@y.",
+ "example.com",
+ "https://example.com/a-b_c?q=x@y",
+ ),
+ (
+ "open \"https://example.com/a,b;c?q=x\";",
+ "example.com",
+ "https://example.com/a,b;c?q=x",
+ ),
+ (
+ "see https://en.wikipedia.org/wiki/Foo_(bar_(baz)),",
+ "wikipedia",
+ "https://en.wikipedia.org/wiki/Foo_(bar_(baz))",
+ ),
+ (
+ "see https://example.com/a(b[c{d}e]f),",
+ "example.com",
+ "https://example.com/a(b[c{d}e]f)",
+ ),
+ (
+ "see (https://example.com/a(b(c)d)))",
+ "example.com",
+ "https://example.com/a(b(c)d)",
+ ),
+ (
+ "open /tmp/foo-bar/baz_qux/",
+ "foo-bar",
+ "/tmp/foo-bar/baz_qux/",
+ ),
+ (
+ "open ./src/app/actions.rs:795",
+ "actions",
+ "./src/app/actions.rs:795",
+ ),
+ (
+ "open ../herdr-worktrees/issue-1",
+ "herdr",
+ "../herdr-worktrees/issue-1",
+ ),
+ (
+ "edit src/app/actions.rs,then",
+ "actions",
+ "src/app/actions.rs",
+ ),
+ (
+ "cat \"/tmp/build output/log.txt\"",
+ "output",
+ "/tmp/build output/log.txt",
+ ),
+ (
+ "cat '/Users/me/Library/Application Support/app/config.json'",
+ "Support",
+ "/Users/me/Library/Application Support/app/config.json",
+ ),
+ ("echo 你好-world done", "好", "你好-world"),
+ ("先跑 cargo test", "cargo", "cargo"),
+ (
+ "export PATH=$HOME/.cargo/bin:$PATH",
+ "$HOME",
+ "PATH=$HOME/.cargo/bin:$PATH",
+ ),
+ (
+ "git checkout feature/foo-bar_baz",
+ "foo",
+ "feature/foo-bar_baz",
+ ),
+ ("refs #123 and @owner/name", "#123", "#123"),
+ ("refs #123 and @owner/name", "owner", "@owner/name"),
+ ("cargo test --package=herdr", "--package", "--package=herdr"),
+ (
+ "cargo test app::actions::tests",
+ "app::",
+ "app::actions::tests",
+ ),
+ (
+ "image ghcr.io/org/app:latest",
+ "ghcr",
+ "ghcr.io/org/app:latest",
+ ),
+ ("ERROR [worker-1] request_id=abc-123", "worker", "worker-1"),
+ (
+ "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
+ "newhoo",
+ "newhoo",
+ ),
+ (
+ "render_status_line(app, area)",
+ "render",
+ "render_status_line",
+ ),
+ ("render_status_line(app, area)", "app", "app"),
+ ("render_status_line(app, area)", "area", "area"),
+ ("if !enabled {", "enabled", "enabled"),
+ ("println!(\"hi\")", "println", "println"),
+ ("( master)$", "master", "master"),
+ ("regex foo$", "foo", "foo$"),
+ ];
+
+ for (row, click, expected) in cases {
+ assert_selects(row, click, expected);
+ }
+
+ let row = "echo 你好-world done";
+ assert_eq!(
+ selected_word(row, col_of(row, "好") + 1).as_deref(),
+ Some("你好-world")
+ );
+ }
+
+ #[test]
+ fn double_click_word_bounds_ignore_delimiters() {
+ for (row, click) in [
+ (
+ "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
+ "|",
+ ),
+ ("alpha,beta;gamma", ","),
+ ("alpha,beta;gamma", ";"),
+ ("render_status_line(app, area)", "("),
+ ("render_status_line(app, area)", ")"),
+ ("if !enabled {", "!"),
+ ("if !enabled {", "{"),
+ ("(done).", "("),
+ ("(done).", "."),
+ ] {
+ assert_selects_nothing(row, click);
+ }
+ }
+
#[test]
fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() {
let mut state = app_with_workspaces(&["single", "multi"]);
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 0f2f575d..0f0b3568 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -2,6 +2,7 @@
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use crate::app::PaneClickState;
use crate::input::TerminalKey;
use ratatui::layout::Direction;
@@ -194,21 +195,27 @@ impl App {
}
}
+ let handled_pane_double_click = self.handle_pane_double_click(mouse);
+
let previous_agent_panel_scope = self.state.agent_panel_scope;
let previous_settings_section = self.state.settings.section;
- if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
- match action {
- SettingsAction::SaveTheme(name) => self.save_theme(&name),
- SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
- SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery),
- SettingsAction::SaveAgentBorderLabels(enabled) => {
- self.save_agent_border_labels(enabled)
- }
- SettingsAction::SavePaneHistory(enabled) => {
- self.save_pane_history_persistence(enabled)
- }
- SettingsAction::InstallRecommendedIntegrations => {
- self.install_recommended_integrations()
+ if !handled_pane_double_click {
+ if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
+ match action {
+ SettingsAction::SaveTheme(name) => self.save_theme(&name),
+ SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
+ SettingsAction::SaveToastDelivery(delivery) => {
+ self.save_toast_delivery(delivery)
+ }
+ SettingsAction::SaveAgentBorderLabels(enabled) => {
+ self.save_agent_border_labels(enabled)
+ }
+ SettingsAction::SavePaneHistory(enabled) => {
+ self.save_pane_history_persistence(enabled)
+ }
+ SettingsAction::InstallRecommendedIntegrations => {
+ self.install_recommended_integrations()
+ }
}
}
}
@@ -240,6 +247,94 @@ impl App {
Some(std::time::Instant::now() + super::SELECTION_AUTOSCROLL_INTERVAL);
}
}
+
+ fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool {
+ // A pane press stops being a double-click candidate once it becomes
+ // a drag or completes as a real text selection.
+ match mouse.kind {
+ MouseEventKind::Drag(MouseButton::Left) => {
+ self.last_pane_click = None;
+ return false;
+ }
+ MouseEventKind::Up(MouseButton::Left)
+ if self
+ .state
+ .selection
+ .as_ref()
+ .is_some_and(|selection| selection.is_visible()) =>
+ {
+ self.last_pane_click = None;
+ return false;
+ }
+ _ => {}
+ }
+
+ // Only terminal-pane left-clicks can start this gesture; other clicks
+ // should keep their existing mouse behavior and clear stale candidates.
+ let Some(click) = self.pane_click_candidate(mouse) else {
+ return false;
+ };
+
+ // 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.
+ 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)
+ }
+
+ fn pane_click_candidate(&mut self, mouse: MouseEvent) -> Option<PaneClickState> {
+ if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ return None;
+ }
+
+ if self.state.mode != Mode::Terminal {
+ self.last_pane_click = None;
+ return None;
+ }
+
+ let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else {
+ self.last_pane_click = None;
+ return None;
+ };
+
+ Some(PaneClickState {
+ pane_id: info.id,
+ viewport_row: mouse.row - info.inner_rect.y,
+ col: mouse.column - info.inner_rect.x,
+ at: std::time::Instant::now(),
+ })
+ }
+
+ fn take_pane_double_click(&mut self, click: PaneClickState) -> bool {
+ if !self
+ .last_pane_click
+ .is_some_and(|last| last.is_double_click_for(click))
+ {
+ self.last_pane_click = Some(click);
+ return false;
+ }
+
+ self.last_pane_click = None;
+ true
+ }
+
+ fn copy_double_clicked_word(&mut self, click: PaneClickState) -> bool {
+ let copied = self.state.copy_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);
+ }
+ copied
+ }
}
// ---------------------------------------------------------------------------
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 87c14756..8adfa55b 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -728,16 +728,19 @@ impl AppState {
}
MouseEventKind::Up(MouseButton::Left) => {
- if self.selection.is_some() {
+ // Mouse-up either finishes a drag selection or releases after a
+ // double-click copy; the latter is already copied.
+ if let Some(selection) = self.selection.as_ref() {
+ let was_click = selection.was_just_click();
+ let was_already_copied = selection.is_done();
+
self.workspace_press = None;
self.tab_press = None;
self.drag = None;
self.selection_autoscroll = None;
- let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
if was_click {
self.selection = None;
- self.selection_autoscroll = None;
- } else {
+ } else if !was_already_copied {
self.copy_selection(terminal_runtimes);
}
return None;
@@ -795,13 +798,6 @@ impl AppState {
return None;
}
}
- let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
- if was_click {
- self.selection = None;
- self.selection_autoscroll = None;
- } else {
- self.copy_selection(terminal_runtimes);
- }
}
}
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index eee5b863..71728be4 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -201,7 +201,51 @@ mod tests {
app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file,
};
use super::*;
- use crate::{config::Config, workspace::Workspace};
+ use crate::{config::Config, events::AppEvent, workspace::Workspace};
+
+ fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) {
+ 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();
+ ws.insert_test_runtime(
+ pane_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ bytes,
+ ),
+ );
+
+ 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)
+ }
+
+ fn double_click(app: &mut App, col: u16, row: u16) {
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 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 dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
@@ -310,6 +354,116 @@ mod tests {
}
#[tokio::test]
+ async fn drag_copy_then_click_does_not_reuse_double_click_candidate() {
+ let (mut app, info) = app_with_screen_bytes(b"alpha beta");
+ let row = info.inner_rect.y;
+ let start_col = info.inner_rect.x;
+ let end_col = info.inner_rect.x + 4;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ row,
+ ));
+ assert!(app.last_pane_click.is_some());
+
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
+ assert!(app.last_pane_click.is_none());
+
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
+ assert!(app.last_pane_click.is_none());
+ assert_eq!(clipboard_write_content(&mut app), b"alpha");
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ row,
+ ));
+
+ assert!(app.last_pane_click.is_some());
+ assert!(app.event_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn double_click_selects_and_copies_word() {
+ let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega");
+ let col = info.inner_rect.x + 13;
+ let row = info.inner_rect.y;
+ double_click(&mut app, col, row);
+
+ assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega");
+ assert_visible_selection(&app);
+ }
+
+ #[tokio::test]
+ async fn double_click_uses_display_columns_for_wide_chars() {
+ let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes());
+ let col = info.inner_rect.x + 8;
+ let row = info.inner_rect.y;
+ double_click(&mut app, col, row);
+
+ assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes());
+ assert_visible_selection(&app);
+ }
+
+ #[tokio::test]
+ async fn double_click_copies_quoted_path_without_quotes() {
+ let line = r#"cat "/tmp/build output/log.txt""#;
+ let (mut app, info) = app_with_screen_bytes(line.as_bytes());
+ let col = info.inner_rect.x + line.find("output").expect("path segment") as u16;
+ let row = info.inner_rect.y;
+ double_click(&mut app, col, row);
+
+ assert_eq!(
+ clipboard_write_content(&mut app),
+ b"/tmp/build output/log.txt"
+ );
+ assert_visible_selection(&app);
+ }
+
+ #[tokio::test]
+ async fn double_click_excludes_trailing_punctuation() {
+ let (mut app, info) = app_with_screen_bytes(b"done.");
+ let col = info.inner_rect.x + 2;
+ let row = info.inner_rect.y;
+ double_click(&mut app, col, row);
+
+ assert_eq!(clipboard_write_content(&mut app), b"done");
+ assert_visible_selection(&app);
+ }
+
+ #[tokio::test]
+ async fn double_click_highlight_clears_after_short_delay() {
+ let (mut app, info) = app_with_screen_bytes(b"copied");
+ let col = info.inner_rect.x + 2;
+ let row = info.inner_rect.y;
+ double_click(&mut app, col, row);
+ assert_eq!(clipboard_write_content(&mut app), b"copied");
+
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
+
+ assert!(app.event_rx.try_recv().is_err());
+ assert!(app.state.selection.is_some());
+ let deadline = app
+ .selection_highlight_clear_deadline
+ .expect("highlight clear deadline");
+ assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1)));
+ assert!(app.state.selection.is_none());
+ }
+
+ #[tokio::test]
+ async fn double_click_is_forwarded_when_mouse_reporting_is_enabled() {
+ let (mut app, info) = app_with_screen_bytes(b"\x1b[?1002halpha beta");
+ let col = info.inner_rect.x + 8;
+ let row = info.inner_rect.y;
+ double_click(&mut app, col, row);
+
+ assert!(app.event_rx.try_recv().is_err());
+ assert!(app.state.selection.is_none());
+ assert!(app.selection_highlight_clear_deadline.is_none());
+ }
+
+ #[tokio::test]
async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 0b4c792c..e4ec2751 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -36,6 +36,8 @@ const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500)
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
+const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
+const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500);
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
@@ -61,6 +63,23 @@ pub(crate) struct OverlayPaneState {
temp_files: Vec<std::path::PathBuf>,
}
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct PaneClickState {
+ pane_id: crate::layout::PaneId,
+ viewport_row: u16,
+ col: u16,
+ at: Instant,
+}
+
+impl PaneClickState {
+ fn is_double_click_for(self, next: Self) -> bool {
+ self.pane_id == next.pane_id
+ && next.at.duration_since(self.at) <= PANE_DOUBLE_CLICK_WINDOW
+ && self.viewport_row.abs_diff(next.viewport_row) <= 1
+ && self.col.abs_diff(next.col) <= 1
+ }
+}
+
pub struct App {
pub state: AppState,
pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry,
@@ -77,10 +96,12 @@ pub struct App {
pub(crate) last_git_remote_status_refresh: Instant,
pub(crate) git_refresh_in_flight: bool,
pub(crate) last_sidebar_divider_click: Option<Instant>,
+ pub(crate) last_pane_click: Option<PaneClickState>,
pub(crate) next_resize_poll: Instant,
pub(crate) next_animation_tick: Option<Instant>,
pub(crate) next_auto_update_check: Option<Instant>,
pub(crate) selection_autoscroll_deadline: Option<Instant>,
+ pub(crate) selection_highlight_clear_deadline: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
@@ -532,12 +553,14 @@ impl App {
last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL,
git_refresh_in_flight: false,
last_sidebar_divider_click: None,
+ last_pane_click: None,
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
next_animation_tick: None,
next_auto_update_check: auto_updates_enabled(no_session)
.then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL),
session_save_deadline: None,
selection_autoscroll_deadline: None,
+ selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
last_render_at: None,
suppressed_repeat_keys: HashSet::new(),
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 85bdb8dd..bbef7444 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -179,6 +179,8 @@ impl App {
changed = true;
}
+ changed |= self.clear_due_selection_highlight(now);
+
self.start_git_status_refresh_if_due(now);
if self
@@ -199,6 +201,28 @@ impl App {
changed
}
+ /// Clears temporary copied-token highlights, such as after double-click copy.
+ pub(crate) fn clear_due_selection_highlight(&mut self, now: Instant) -> bool {
+ if self
+ .selection_highlight_clear_deadline
+ .is_none_or(|deadline| now < deadline)
+ {
+ return false;
+ }
+
+ self.selection_highlight_clear_deadline = None;
+ if self
+ .state
+ .selection
+ .as_ref()
+ .is_some_and(|selection| !selection.is_in_progress())
+ {
+ self.state.clear_selection();
+ return true;
+ }
+ false
+ }
+
pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
}
@@ -412,6 +436,7 @@ impl App {
self.next_auto_update_check,
self.session_save_deadline,
self.selection_autoscroll_deadline,
+ self.selection_highlight_clear_deadline,
render_deadline,
]
.into_iter()
diff --git a/src/selection.rs b/src/selection.rs
index 016cf649..35e44c1d 100644
--- a/src/selection.rs
+++ b/src/selection.rs
@@ -7,6 +7,8 @@
//! MouseUp → Text extracted, copied via OSC 52, highlight stays
//! Next click / key → Selection cleared
//!
+//! Double-click copy also briefly highlights the selected word.
+//!
//! Rows are stored in screen-buffer coordinates instead of viewport-relative
//! coordinates. That keeps selection stable while the pane scrolls.
@@ -59,6 +61,23 @@ impl Selection {
}
}
+ /// Create an active selection from an explicit viewport-row range.
+ pub(crate) fn range(
+ pane_id: PaneId,
+ viewport_row: u16,
+ start_col: u16,
+ end_col: u16,
+ metrics: Option<ScrollMetrics>,
+ ) -> Self {
+ let row = absolute_row_for_viewport_row(viewport_row, metrics);
+ Self {
+ pane_id,
+ anchor: (row, start_col),
+ cursor: (row, end_col),
+ phase: Phase::Dragging,
+ }
+ }
+
/// Convert the anchor's absolute row and pane-relative column back to
/// screen coordinates. Adds the pane origin before clamping so the
/// returned (screen_row, screen_col) can be compared directly against
@@ -114,6 +133,11 @@ impl Selection {
self.phase == Phase::Dragging || self.phase == Phase::Done
}
+ /// Whether this selection was already finalized and copied.
+ pub fn is_done(&self) -> bool {
+ self.phase == Phase::Done
+ }
+
/// Whether the user just clicked without dragging (not a selection).
pub fn was_just_click(&self) -> bool {
self.phase == Phase::Anchored
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 2b8550b4..4d9a2cc5 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2449,6 +2449,8 @@ impl HeadlessServer {
changed = true;
}
+ changed |= self.app.clear_due_selection_highlight(now);
+
self.app.start_git_status_refresh_if_due(now);
if self