aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-06 16:27:52 +0200
committerGitHub <noreply@github.com>2026-09-06 16:27:52 +0200
commit9d111db9c83823eb4ce8623d9707e28153b01334 (patch)
tree58ff584b1fc79e23dc00f093860908c9bad4f2b7 /src
parent5a64e8da4551990367dd26d77cf3b654dfa27a73 (diff)
fix: keep manual selections active during terminal output (#3684)
Diffstat (limited to 'src')
-rw-r--r--src/app/api/panes.rs26
-rw-r--r--src/client/shell/actions.rs9
-rw-r--r--src/client/shell/copy_mode.rs2
-rw-r--r--src/client/shell/input.rs2
-rw-r--r--src/client/shell/mouse.rs2
-rw-r--r--src/client/shell/state.rs12
-rw-r--r--src/client/shell/tests/copy.rs81
-rw-r--r--src/client/shell/tests/endpoint_requests.rs27
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(&params).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!(
&copy.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 &copy.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!(&copy.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]