diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-06 16:27:52 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 16:27:52 +0200 |
| commit | 9d111db9c83823eb4ce8623d9707e28153b01334 (patch) | |
| tree | 58ff584b1fc79e23dc00f093860908c9bad4f2b7 /src | |
| parent | 5a64e8da4551990367dd26d77cf3b654dfa27a73 (diff) | |
fix: keep manual selections active during terminal output (#3684)
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api/panes.rs | 26 | ||||
| -rw-r--r-- | src/client/shell/actions.rs | 9 | ||||
| -rw-r--r-- | src/client/shell/copy_mode.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 12 | ||||
| -rw-r--r-- | src/client/shell/tests/copy.rs | 81 | ||||
| -rw-r--r-- | src/client/shell/tests/endpoint_requests.rs | 27 |
8 files changed, 120 insertions, 41 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index f1c3a667..9b9a28cc 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2419,15 +2419,25 @@ mod tests { ), ); - let response = app.handle_pane_selection_read( - "req".into(), - PaneSelectionReadParams { - pane_id: public_pane_id.clone(), - anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, - cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, - content_revision: None, - }, + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .unwrap(); + let revision = runtime.content_seq(); + runtime.test_process_pty_bytes(b"\r\nagent is still working"); + assert_ne!(runtime.content_seq(), revision); + let mut params = PaneSelectionReadParams { + pane_id: public_pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, + content_revision: Some(revision), + }; + assert_eq!( + app.pane_selection_text(¶ms).unwrap_err().0, + "stale_content" ); + params.content_revision = None; + let response = app.handle_pane_selection_read("req".into(), params); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index e62d75ff..834169fe 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -247,7 +247,7 @@ impl ClientShellState { } } - pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) { + pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput, live: bool) { let Some(selection) = self.selection.as_ref() else { return; }; @@ -256,7 +256,10 @@ impl ClientShellState { .pane_surface .as_ref() .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) - .map(|pane| pane.content_revision); + .map(|pane| pane.content_revision) + // Read a manual mouse selection atomically from the live terminal. Output + // between the displayed frame and this request must not reject the copy. + .filter(|_| !live); let (anchor, cursor) = selection.ordered_cells(); self.push_endpoint_method_with_kind( crate::api::schema::Method::PaneSelectionRead( @@ -700,7 +703,7 @@ impl ClientShellState { self.selection_highlight_clear_deadline = Some(std::time::Instant::now() + std::time::Duration::from_millis(500)); let mut outcome = ClientShellInput::default(); - self.request_selection_copy(&mut outcome); + self.request_selection_copy(&mut outcome, false); return (true, outcome.actions); } PendingEndpointKind::PaneLinkActivate { diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs index 32a17bf4..f08c4afb 100644 --- a/src/client/shell/copy_mode.rs +++ b/src/client/shell/copy_mode.rs @@ -858,7 +858,7 @@ impl ClientShellState { .as_ref() .is_some_and(crate::selection::Selection::is_visible) { - self.request_selection_copy(outcome); + self.request_selection_copy(outcome, false); } self.selection = None; self.selection_highlight_clear_deadline = None; diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 539dec6e..9b5fff21 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -523,7 +523,7 @@ impl ClientShellState { .as_ref() .is_some_and(crate::selection::Selection::is_visible) { - self.request_selection_copy(outcome); + self.request_selection_copy(outcome, true); self.selection = None; self.stop_selection_autoscroll(); self.selection_highlight_clear_deadline = None; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 48646f28..cd4a6c16 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1649,7 +1649,7 @@ impl ClientShellState { .as_mut() .is_some_and(crate::selection::Selection::finish); if copied && self.config.copy_on_select { - self.request_selection_copy(outcome); + self.request_selection_copy(outcome, false); self.selection = None; } else if !copied { self.selection = None; diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index b30f57d0..81845d35 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -1643,19 +1643,21 @@ impl ClientShellState { let (Some(previous), Some(next)) = (previous, next) else { return false; }; - previous.content_revision != next.content_revision + previous.inner_rect.width != next.inner_rect.width + || previous.inner_rect.height != next.inner_rect.height + || previous.alternate_screen_active != next.alternate_screen_active + // Manual mouse selections track a live buffer range, not a content revision. + || (self.config.copy_on_select + && previous.content_revision != next.content_revision && (!previous.content_revision.is_multiple_of(2) || !next.content_revision.is_multiple_of(2) - || previous.inner_rect.width != next.inner_rect.width - || previous.inner_rect.height != next.inner_rect.height - || previous.alternate_screen_active != next.alternate_screen_active || !selection_cells_unchanged( selection, previous_surface, previous, &surface, next, - )) + ))) }); if selection_content_changed { self.selection = None; diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 7cc3241b..95413f8a 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -165,7 +165,7 @@ fn clipboard_feedback_is_client_local_and_respects_config() { } #[test] -fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { +fn retained_mouse_selection_survives_output_and_copies_without_terminal_input() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.config.copy_on_select = false; state.set_snapshot(Box::new(snapshot())); @@ -193,22 +193,51 @@ fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { }, ] { state.handle_raw_events(vec![RawInputEvent::Mouse(event)]); + // Output can arrive between drag and release, including an in-flight revision. + let mut updated = state.pane_surface.clone().expect("pane surface"); + updated.panes[0].content_revision += 1; + updated.frame.cells[0].symbol = "x".into(); + state.set_pane_surface(updated); } assert!(state .selection .as_ref() .is_some_and(crate::selection::Selection::is_finalized)); - // Agent output outside the selected text must not cancel a pending copy. + // A patch that redraws selected text must retain the same live terminal range. let mut updated = state.pane_surface.clone().expect("pane surface"); - updated.panes[0].content_revision = 2; - updated.frame.cells[4].symbol = "-".into(); - state.set_pane_surface(updated); + updated.panes[0].content_revision += 1; + let mut cell = updated.frame.cells[0].clone(); + cell.symbol = "y".into(); + assert!(matches!( + state.apply_pane_surface_patch(crate::protocol::PaneSurfacePatch { + boot_id: updated.boot_id, + projection_revision: updated.projection_revision, + base_surface_revision: updated.surface_revision, + surface_revision: updated.surface_revision + 1, + panes: updated.panes, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![cell] + }], + cursor: updated.frame.cursor, + }), + super::super::surface_patch::ClientPaneSurfacePatchOutcome::Applied(_) + )); assert!(state .selection .as_ref() .is_some_and(crate::selection::Selection::is_finalized)); + let highlighted = state.compose(106, 20).expect("highlighted frame"); + let cell_index = usize::from(pane.inner_rect.y) * 106 + usize::from(pane.inner_rect.x); + let selected_cell = highlighted.cells[cell_index].clone(); + let selection = state.selection.take(); + let unselected = state.compose(106, 20).expect("unselected frame"); + assert_ne!(selected_cell.bg, unselected.cells[cell_index].bg); + state.selection = selection; + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( KeyCode::Char('c'), KeyModifiers::CONTROL, @@ -217,7 +246,9 @@ fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { assert!(matches!( ©.actions[..], [ClientShellAction::Endpoint { request, .. }] - if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + if matches!(request.method, crate::api::schema::Method::PaneSelectionRead( + crate::api::schema::PaneSelectionReadParams { content_revision: None, .. } + )) )); assert!(copy.requests.is_empty()); let request_id = match ©.actions[0] { @@ -229,13 +260,10 @@ fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { &request_id, Ok(crate::api::schema::ResponseResult::PaneSelection { pane_id: "pane_1".into(), - text: String::new(), + text: "yIV".into(), }), ); - assert!( - actions.is_empty(), - "failed copy must not send terminal input" - ); + assert!(matches!(&actions[..], [ClientShellAction::ClipboardWrite(bytes)] if bytes == b"yIV")); } #[test] @@ -296,6 +324,7 @@ fn selection_edge_drag_requests_scroll_and_timer_continues_it() { #[test] fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.copy_on_select = false; state.set_snapshot(Box::new(snapshot())); let mut pane_surface = surface(); pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { @@ -397,7 +426,8 @@ fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { assert!(copy.actions.iter().any(|action| matches!( action, ClientShellAction::Endpoint { request, .. } - if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.content_revision == Some(0)) ))); assert!(copy.actions.iter().any(|action| matches!( action, @@ -797,6 +827,7 @@ fn navigator_owns_search_mouse_selection_and_stable_target_focus() { #[test] fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.copy_on_select = false; state.set_snapshot(Box::new(snapshot())); let mut pane_surface = surface(); pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { @@ -870,6 +901,24 @@ fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { .as_ref() .is_some_and(|selection| selection.pane_id == "pane_2")); + let mut other_surface = surface(); + other_surface.panes[0].pane_id = "pane_2".into(); + state.set_pane_surface(other_surface.clone()); + other_surface.surface_revision += 1; + other_surface.panes[0].content_revision = 1; + state.set_pane_surface(other_surface); + assert!(state.selection.is_some()); + let copy = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))]); + assert!(copy.requests.is_empty()); + assert!( + matches!(©.actions[..], [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.pane_id == "pane_2" && params.content_revision.is_none())) + ); + state.set_snapshot(Box::new(snapshot())); assert_eq!(state.mode, ClientShellMode::Copy); assert!(state.copy_mode.is_some()); @@ -928,10 +977,16 @@ fn retained_selection_copy_suppresses_key_repeats() { if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) ))); let repeat = state.handle_raw_events(vec![RawInputEvent::Key( - key.with_kind(crossterm::event::KeyEventKind::Repeat), + key.clone() + .with_kind(crossterm::event::KeyEventKind::Repeat), )]); assert!(repeat.actions.is_empty()); assert!(repeat.requests.is_empty()); + let release = state.handle_raw_events(vec![RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Release), + )]); + assert!(release.actions.is_empty()); + assert!(release.requests.is_empty()); } #[test] diff --git a/src/client/shell/tests/endpoint_requests.rs b/src/client/shell/tests/endpoint_requests.rs index ab5a3cb2..f05b8462 100644 --- a/src/client/shell/tests/endpoint_requests.rs +++ b/src/client/shell/tests/endpoint_requests.rs @@ -216,7 +216,7 @@ fn cancelled_integration_install_does_not_queue_a_refresh() { } #[test] -fn cancelled_selection_does_not_send_terminal_input() { +fn failed_selection_copy_does_not_send_terminal_input() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); state.set_pane_surface(surface()); @@ -225,18 +225,27 @@ fn cancelled_selection_does_not_send_terminal_input() { (0, 0), (0, 2), )); - let mut outcome = ClientShellInput::default(); - state.request_selection_copy(&mut outcome); - let (_, actions) = state.handle_endpoint_result( - "boot-1", - request_id(&outcome.actions), + for result in [ + Ok(crate::api::schema::ResponseResult::PaneSelection { + pane_id: "pane_1".into(), + text: String::new(), + }), Err(ClientShellEndpointError { code: Some("endpoint_cancelled".into()), message: "cancelled".into(), }), - ); - assert!(actions.is_empty()); - assert!(state.pending_requests.is_empty()); + Err(ClientShellEndpointError { + code: Some("selection_unavailable".into()), + message: "selection text is unavailable".into(), + }), + ] { + let mut outcome = ClientShellInput::default(); + state.request_selection_copy(&mut outcome, false); + let (_, actions) = + state.handle_endpoint_result("boot-1", request_id(&outcome.actions), result); + assert!(actions.is_empty()); + assert!(state.pending_requests.is_empty()); + } } #[test] |
