From e1a552ec997a6bd55579cbc59da177ed922c41a3 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Fri, 10 Jul 2026 11:42:25 +0800 Subject: [PATCH] feat(clipboard): add copy-on-select (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting with the mouse — drag, double-click word, triple-click line, over terminal output or the prompt's command editor — copies the selection the moment the gesture ends, no Cmd+C needed. Opt-in via Settings -> Terminal -> Clipboard (config key copy_on_select), off by default so a stray selection never overwrites the clipboard. Closes #34 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- CHANGELOG.md | 8 +++ src/core/config.rs | 6 ++ src/terminal/view.rs | 138 +++++++++++++++++++++++++++++++++++++++++-- src/ui/app.rs | 4 ++ src/ui/settings.rs | 11 ++++ 5 files changed, 162 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78dcdc3a..2805b58d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Copy on select: an opt-in Settings → Terminal → Clipboard toggle (config + key `copy_on_select`) that copies a mouse selection — drag, double-click + word, or triple-click line, over terminal output or the prompt's command + editor — to the clipboard the moment the gesture ends, no ⌘C needed. Off + by default so a stray selection never overwrites the clipboard. (#34) + ### Fixed - The held-⌘ tab-number badges no longer stick on after the window loses key diff --git a/src/core/config.rs b/src/core/config.rs index f86e5454..6b6cf966 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -106,6 +106,11 @@ pub struct Config { pub mouse_scroll_multiplier: f32, /// Drop trailing whitespace from each copied line. Off by default. pub clipboard_trim_trailing_spaces: bool, + /// Copy a mouse selection to the clipboard as soon as the gesture ends, + /// without ⌘C (à la Ghostty/iTerm2's copy-on-select). Off by default — + /// the clipboard is never overwritten by a stray selection unless opted + /// into. + pub copy_on_select: bool, /// Window state at launch: normal / maximized / fullscreen. #[serde(default, deserialize_with = "de_lenient")] pub startup_mode: StartupMode, @@ -271,6 +276,7 @@ impl Default for Config { focus_follows_mouse: false, mouse_scroll_multiplier: 1.0, clipboard_trim_trailing_spaces: false, + copy_on_select: false, startup_mode: StartupMode::Normal, working_directory: WorkingDirectory::default(), env: HashMap::new(), diff --git a/src/terminal/view.rs b/src/terminal/view.rs index a259612e..ab5a632a 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -213,6 +213,12 @@ pub struct TerminalView { /// True while a left-drag that began on the command-editor line is in progress, /// so mouse-move extends the editor selection rather than the terminal's. editor_selecting: bool, + /// True from a left press on the command-editor line until its release — + /// unlike [`Self::editor_selecting`] it also covers the double/triple-click + /// word/line selections, which don't arm drag-extend. Tells the mouse-up + /// that the ended gesture selected in the editor, so copy-on-select copies + /// the editor's selection rather than the terminal's. + editor_select_gesture: bool, /// The URL currently under the mouse (an OSC 8 hyperlink or a bare URL found /// in the row text), if any. Drives the hover underline and the pointing-hand /// cursor that mark a link as clickable. Stored in scroll-stable grid @@ -659,6 +665,7 @@ impl TerminalView { completion: None, reverse_search: None, editor_selecting: false, + editor_select_gesture: false, hovered_link: None, _focus_subs: focus_subs, } @@ -1869,6 +1876,7 @@ impl TerminalView { 2 => self.cmd.select_word_at(idx), _ => self.cmd.select_all(), } + self.editor_select_gesture = true; self.completion = None; self.cursor_visible = true; cx.notify(); @@ -2533,10 +2541,30 @@ impl TerminalView { true } - pub fn on_select_end(&mut self, _cx: &mut Context) { + /// Mouse-up: the selection gesture (if any) is over. With copy-on-select + /// enabled, the selection the gesture drove goes straight to the clipboard + /// — [`select_end_copy`] picks the buffer, and empty selections (a plain + /// click repositioning the caret / collapsing the old selection) write + /// nothing because both copy paths drop empty text. + pub fn on_select_end(&mut self, cx: &mut Context) { + let copy = select_end_copy( + cx.global::().copy_on_select, + self.selecting, + self.editor_select_gesture, + ); self.selecting = false; self.editor_selecting = false; + self.editor_select_gesture = false; self.drag_scroll = None; + match copy { + SelectEndCopy::None => {} + SelectEndCopy::Grid => self.copy_selection(cx), + SelectEndCopy::Editor => { + if let Some(text) = self.cmd.selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + } } fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context) { @@ -3330,6 +3358,30 @@ fn wheel_route(mode: TermMode, shift: bool, up: bool) -> WheelRoute { WheelRoute::Scrollback } +/// What a finished mouse-selection gesture should auto-copy when +/// copy-on-select is enabled (see `Config::copy_on_select`). +#[derive(Debug, PartialEq)] +enum SelectEndCopy { + /// Feature off, or the mouse-up ended no selection gesture (a plain + /// click, a right/middle release): leave the clipboard alone. + None, + /// The gesture drove the terminal grid selection (drag / double / triple + /// click over output): copy `term.selection`. + Grid, + /// The gesture landed on the command editor's line: copy the editor's + /// own selection. + Editor, +} + +fn select_end_copy(enabled: bool, grid: bool, editor: bool) -> SelectEndCopy { + match (enabled, grid, editor) { + (false, ..) => SelectEndCopy::None, + (true, true, _) => SelectEndCopy::Grid, + (true, false, true) => SelectEndCopy::Editor, + (true, false, false) => SelectEndCopy::None, + } +} + /// One mouse report, encoded for the protocol the app negotiated. SGR (1006) /// prints decimal 1-based coordinates and keeps the button in the final /// letter (`M` press / `m` release); X10 packs everything into three bytes, @@ -3612,10 +3664,10 @@ fn drag_scroll_step(overshoot: f32) -> i32 { #[cfg(test)] mod tests { use super::{ - WheelRoute, clipboard_paste_text, display_width, drag_scroll_step, encode_mouse, - fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, menu_layout, - paste_bytes, shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route, - wrapped_click_index, + SelectEndCopy, WheelRoute, clipboard_paste_text, display_width, drag_scroll_step, + encode_mouse, fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, + menu_layout, paste_bytes, select_end_copy, shell_escape_path, smooth_scroll_step, + trim_trailing_spaces, wheel_route, wrapped_click_index, }; use alacritty_terminal::term::TermMode; use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers}; @@ -3724,6 +3776,29 @@ mod tests { assert_eq!(wheel_route(everything, true, false), WheelRoute::Scrollback); } + /// Copy-on-select fires only when the released gesture actually drove a + /// selection, and copies the buffer that gesture touched — the terminal + /// grid or the command editor's line. Off, or a mouse-up that ended no + /// gesture (a plain click, a right-click), must leave the clipboard alone. + #[test] + fn copy_on_select_copies_the_buffer_the_gesture_touched() { + // Disabled: never copy, whatever kind of gesture just ended. + assert_eq!(select_end_copy(false, true, false), SelectEndCopy::None); + assert_eq!(select_end_copy(false, false, true), SelectEndCopy::None); + + // A grid gesture (drag / double / triple click over output) copies + // the terminal selection; one on the editor line copies the editor's. + assert_eq!(select_end_copy(true, true, false), SelectEndCopy::Grid); + assert_eq!(select_end_copy(true, false, true), SelectEndCopy::Editor); + + // No gesture ended → nothing to copy. + assert_eq!(select_end_copy(true, false, false), SelectEndCopy::None); + + // The press routes to exactly one buffer, but if both flags ever + // read set, the grid selection (the visible one) wins. + assert_eq!(select_end_copy(true, true, true), SelectEndCopy::Grid); + } + /// SGR (1006) reports print 1-based decimal coordinates, stack the /// modifier bits onto the button code, and carry press/release in the /// final letter. A drift in any of these lands clicks one cell off in @@ -4381,6 +4456,59 @@ mod gpui_tests { assert_eq!(wait_for(cx, "hello again"), "hello again"); } + /// Copy-on-select, end to end: real output through the pump, the same + /// start/update/end calls the mouse handlers make, then the clipboard. + /// Off (the default) the release must leave the clipboard alone; on, the + /// selected text lands at mouse-up with no ⌘C. + #[gpui::test] + fn copy_on_select_writes_the_clipboard_at_mouse_up(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + + DaemonMsg::Output(b"hello world".to_vec()) + .encode(&mut daemon) + .unwrap(); + // Bounded poll for the reader thread, as in the pump test above. + for _ in 0..400 { + cx.run_until_parked(); + let row: String = window + .update(cx, |view, _, _| { + let term = view.terminal.term.clone(); + let term = term.lock(); + (0..11) + .map(|c| term.grid()[alacritty_terminal::index::Line(0)][Column(c)].c) + .collect() + }) + .unwrap(); + if row == "hello world" { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + // Drag across "hello" and release with the feature off: no copy. + let drag_hello = |cx: &mut TestAppContext| { + window + .update(cx, |view, _, cx| { + view.on_select_start(0, 0, true, 1, cx); + view.on_select_update(4, 0, false, cx); + view.on_select_end(cx); + }) + .unwrap(); + }; + drag_hello(cx); + assert_eq!( + cx.update(|cx| cx.read_from_clipboard()), + None, + "default-off must never write the clipboard" + ); + + // Same gesture with the feature on: "hello" is on the clipboard. + cx.update(|cx| cx.update_global::(|cfg, _| cfg.copy_on_select = true)); + drag_hello(cx); + let text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())); + assert_eq!(text.as_deref(), Some("hello")); + } + /// Reproduces the "orange caret jumps to the top-left corner after Claude /// Code exits" bug at the state level, driving the two conditions that must /// co-occur to trigger it: diff --git a/src/ui/app.rs b/src/ui/app.rs index ad830f8f..595fe4f2 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -766,6 +766,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on); } + pub(crate) fn set_copy_on_select(&mut self, on: bool, cx: &mut Context) { + self.update_config(cx, |cfg| cfg.copy_on_select = on); + } + pub(crate) fn set_startup_mode( &mut self, mode: crate::core::config::StartupMode, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 45cec713..a0f72060 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1014,6 +1014,7 @@ impl Tty7App { let option_as_alt = cfg.macos_option_as_alt; let scroll_mult = cfg.mouse_scroll_multiplier; let clip_trim = cfg.clipboard_trim_trailing_spaces; + let copy_on_select = cfg.copy_on_select; // Map the persisted scrollback depth onto its preset radio index (default // to 10k's slot for any off-preset value a hand-edit might leave). let scrollback_idx = match cfg.scrollback_limit { @@ -1078,6 +1079,10 @@ impl Tty7App { .checked(clip_trim) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx))) .into_any_element(); + let copy_on_select_switch = Switch::new("term-copy-on-select") + .checked(copy_on_select) + .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx))) + .into_any_element(); // macOS only: the Option/special-character split this toggle resolves // doesn't exist on other platforms, where Alt always carries Meta. let option_alt_row = cfg!(target_os = "macos").then(|| { @@ -1153,6 +1158,12 @@ impl Tty7App { )) .child(self.section_rule(cx)) .child(self.section_header("Clipboard", cx)) + .child(self.settings_row( + "Copy on select", + "Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed.", + copy_on_select_switch, + cx, + )) .child(self.settings_row( "Trim trailing spaces on copy", "Strip trailing whitespace from each copied line.",