Skip to content

Commit 57093a1

Browse files
committed
release: v0.3.1 — window-targeted scroll defaults to the target window centre
A scroll with a window target but no x/y/element_index focused the window and then sent wheel events without moving the pointer, scrolling whatever happened to sit under the cursor while reporting success. Window-targeted scroll now defaults its point to the centre of the resolved window (and errors with a pass-x/y hint when bounds are unavailable). Scroll results also gain the same off-screen coordinate warning click already had. Raised by @ilysenko in codex-desktop-linux#650 review.
1 parent 298c4c4 commit 57093a1

4 files changed

Lines changed: 154 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "computer-use-linux"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
edition = "2021"
55
authors = ["Avi Fenesh <avifenesh@users.noreply.github.com>"]
66
license = "MIT"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@agent-sh/computer-use-linux",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Linux desktop-control MCP server: AT-SPI accessibility trees, Wayland/X11 input, screenshots, and compositor window targeting.",
55
"license": "MIT",
66
"type": "commonjs",

src/server.rs

Lines changed: 151 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,7 @@ impl ComputerUseLinux {
804804

805805
#[tool(
806806
name = "scroll",
807-
description = "Scroll an element in a direction by a number of pages.",
807+
description = "Scroll an element in a direction by a number of pages. With a window target and no x/y/element_index, scrolls at the centre of the targeted window.",
808808
annotations(
809809
read_only_hint = false,
810810
destructive_hint = false,
@@ -862,6 +862,30 @@ impl ComputerUseLinux {
862862
received,
863863
});
864864
}
865+
} else if params.x.is_none() && params.y.is_none() && params.element_index.is_none() {
866+
// A window target without a point would otherwise scroll
867+
// whatever happens to sit under the pointer: focusing does not
868+
// move the cursor, and the wheel path never repositions it.
869+
// Default to the centre of the resolved target window.
870+
let Some(focus) = focus.as_ref() else {
871+
return Json(ActionOutput {
872+
ok: false,
873+
implemented: true,
874+
action: "scroll".to_string(),
875+
message: "Window-targeted scroll requires verified target-window focus."
876+
.to_string(),
877+
received,
878+
});
879+
};
880+
if let Err(message) = apply_window_center_scroll_point(&mut params, focus) {
881+
return Json(ActionOutput {
882+
ok: false,
883+
implemented: true,
884+
action: "scroll".to_string(),
885+
message,
886+
received,
887+
});
888+
}
865889
}
866890
}
867891
let target_point =
@@ -893,36 +917,47 @@ impl ComputerUseLinux {
893917
});
894918
}
895919
};
920+
let off_screen_note = match target_point {
921+
Some((x, y)) => self.off_screen_note_for_point(x, y).await,
922+
None => None,
923+
};
896924

897925
if let Some(session) = self.cached_portal_pointer_session() {
898926
match portal_scroll(&session, target_point, direction, units).await {
899927
Ok(()) => {
900-
return Json(ActionOutput {
901-
ok: true,
902-
implemented: true,
903-
action: "scroll".to_string(),
904-
message: "Action sent through the remote desktop portal.".to_string(),
905-
received,
906-
});
907-
}
908-
Err(_) => self.clear_portal_pointer_session(),
909-
}
910-
} else if self.should_prefer_portal_pointer_backend() {
911-
match self.ensure_portal_pointer_session().await {
912-
Ok(Some(session)) => match portal_scroll(&session, target_point, direction, units)
913-
.await
914-
{
915-
Ok(()) => {
916-
return Json(ActionOutput {
928+
return Json(with_notes(
929+
ActionOutput {
917930
ok: true,
918931
implemented: true,
919932
action: "scroll".to_string(),
920933
message: "Action sent through the remote desktop portal.".to_string(),
921934
received,
922-
});
935+
},
936+
off_screen_note.clone(),
937+
));
938+
}
939+
Err(_) => self.clear_portal_pointer_session(),
940+
}
941+
} else if self.should_prefer_portal_pointer_backend() {
942+
match self.ensure_portal_pointer_session().await {
943+
Ok(Some(session)) => {
944+
match portal_scroll(&session, target_point, direction, units).await {
945+
Ok(()) => {
946+
return Json(with_notes(
947+
ActionOutput {
948+
ok: true,
949+
implemented: true,
950+
action: "scroll".to_string(),
951+
message: "Action sent through the remote desktop portal."
952+
.to_string(),
953+
received,
954+
},
955+
off_screen_note.clone(),
956+
));
957+
}
958+
Err(_) => self.clear_portal_pointer_session(),
923959
}
924-
Err(_) => self.clear_portal_pointer_session(),
925-
},
960+
}
926961
Ok(None) => {}
927962
Err(_) => {}
928963
}
@@ -949,7 +984,10 @@ impl ComputerUseLinux {
949984
}
950985
sequence.push(wheel_mousemove_args(dx, dy));
951986
let result = run_ydotool_sequence(&sequence).await;
952-
Json(action_result("scroll", result, received))
987+
Json(with_notes(
988+
action_result("scroll", result, received),
989+
off_screen_note,
990+
))
953991
}
954992

955993
#[tool(
@@ -1260,7 +1298,7 @@ impl ComputerUseLinux {
12601298
// The rmcp tool_handler macro only accepts a string literal here, so this
12611299
// can't be env!("CARGO_PKG_VERSION"); the MCP safety check (CI) fails the
12621300
// build if it drifts from the Cargo version.
1263-
version = "0.3.0",
1301+
version = "0.3.1",
12641302
instructions = "Begin every turn that uses Computer Use by calling get_app_state. If diagnostics report disabled GNOME accessibility, call setup_accessibility before asking the user to retry. Use list_windows/focused_window before targeted keyboard input. If diagnostics report windowing.can_list_windows=false on GNOME, call setup_window_targeting to install the optional GNOME Shell extension backend, then ask the user to log out and back in if the setup report says a shell reload is required. This Linux backend can capture size-bounded screenshots through GNOME Shell or XDG Desktop Portal, read AT-SPI trees with action/value metadata, invoke native AT-SPI actions, set AT-SPI values or editable text, list/focus compositor windows through registered Linux window backends when the session permits it, attach best-effort terminal tty/process metadata to terminal windows, send coordinate or element-targeted click/scroll/drag input through the Wayland remote desktop portal when available, and send layout-safe literal type_text through KDE clipboard integration on Plasma Wayland or through portal keysyms on other Wayland sessions before falling back to ydotool. Screenshot results include width/height for the returned image plus coordinate_width/coordinate_height and scale for desktop coordinate conversion; request more detail with max_width, max_height, max_bytes, format=jpeg, quality, or a smaller target/crop instead of relying on unbounded screenshots. Tools with readOnlyHint=false may mutate local desktop or application state; hosts should require approval for actions that can submit, delete, send, purchase, or overwrite data. For element-targeted actions, prefer element_index from the latest get_app_state result; click, perform_action, and set_value can also use semantic role/name/text/states selectors when the target is unique. type_text and press_key accept optional window_id, pid, app_id, wm_class, title, tty, terminal_pid, terminal_command, or terminal_cwd selectors and refuse targeted input if focus cannot be verified. After targeted keyboard input, results append focused-element feedback from AT-SPI (role, name, editable) and warn when no editable element holds focus — treat that warning as the input not landing. Screenshot, click, and input results warn when the target window or coordinate is partially or fully off-screen; use move_window/resize_window (GNOME Shell extension backend) to bring a window fully on-screen before retrying. scroll accepts the same window targeting and relative coordinates as click. get_app_state returns a compact readiness block by default; pass verbose=true for the full diagnostics dump. Electron apps expose no AT-SPI tree unless launched with --force-renderer-accessibility."
12651303
)]
12661304
impl ServerHandler for ComputerUseLinux {}
@@ -2948,6 +2986,37 @@ fn apply_window_relative_click_coordinates(
29482986
Ok(())
29492987
}
29502988

2989+
/// Point a window-targeted scroll at the centre of the resolved window when
2990+
/// the caller supplied no coordinates. Without this the wheel events land on
2991+
/// whatever is under the current pointer position.
2992+
fn apply_window_center_scroll_point(
2993+
params: &mut ScrollParams,
2994+
focus: &WindowFocusResult,
2995+
) -> std::result::Result<(), String> {
2996+
let bounds = focus
2997+
.focused_window
2998+
.as_ref()
2999+
.and_then(|window| window.bounds.as_ref())
3000+
.or(focus.requested_window.bounds.as_ref())
3001+
.ok_or_else(|| {
3002+
"Window-targeted scroll requires resolved target-window bounds; pass x/y explicitly."
3003+
.to_string()
3004+
})?;
3005+
if bounds.width == 0 || bounds.height == 0 {
3006+
return Err(
3007+
"Window-targeted scroll requires non-empty target-window bounds; pass x/y explicitly."
3008+
.to_string(),
3009+
);
3010+
}
3011+
let (origin_x, origin_y) = bounds.x.zip(bounds.y).ok_or_else(|| {
3012+
"Window-targeted scroll requires target-window bounds with an origin; pass x/y explicitly."
3013+
.to_string()
3014+
})?;
3015+
params.x = Some(origin_x.saturating_add((bounds.width / 2) as i32));
3016+
params.y = Some(origin_y.saturating_add((bounds.height / 2) as i32));
3017+
Ok(())
3018+
}
3019+
29513020
fn apply_window_relative_scroll_coordinates(
29523021
params: &mut ScrollParams,
29533022
focus: &WindowFocusResult,
@@ -4773,6 +4842,65 @@ mod tests {
47734842
assert_eq!(params.y, Some(220));
47744843
}
47754844

4845+
#[test]
4846+
fn window_targeted_scroll_defaults_to_window_center() {
4847+
let mut params = ScrollParams {
4848+
element_index: None,
4849+
x: None,
4850+
y: None,
4851+
direction: "down".to_string(),
4852+
pages: None,
4853+
window_id: Some(1),
4854+
pid: None,
4855+
app_id: None,
4856+
wm_class: None,
4857+
window_title: None,
4858+
relative: None,
4859+
};
4860+
let focus = WindowFocusResult {
4861+
requested_window: window_with_bounds(1, 100, 200, 800, 600),
4862+
focused_window: None,
4863+
app_focused: true,
4864+
exact_window_focused: true,
4865+
backend: "test".to_string(),
4866+
note: String::new(),
4867+
};
4868+
apply_window_center_scroll_point(&mut params, &focus).unwrap();
4869+
assert_eq!(params.x, Some(500));
4870+
assert_eq!(params.y, Some(500));
4871+
}
4872+
4873+
#[test]
4874+
fn window_targeted_scroll_without_bounds_errors() {
4875+
let mut params = ScrollParams {
4876+
element_index: None,
4877+
x: None,
4878+
y: None,
4879+
direction: "down".to_string(),
4880+
pages: None,
4881+
window_id: Some(1),
4882+
pid: None,
4883+
app_id: None,
4884+
wm_class: None,
4885+
window_title: None,
4886+
relative: None,
4887+
};
4888+
let mut window = window_with_bounds(1, 0, 0, 1, 1);
4889+
window.bounds = None;
4890+
let focus = WindowFocusResult {
4891+
requested_window: window,
4892+
focused_window: None,
4893+
app_focused: true,
4894+
exact_window_focused: true,
4895+
backend: "test".to_string(),
4896+
note: String::new(),
4897+
};
4898+
let error = apply_window_center_scroll_point(&mut params, &focus).unwrap_err();
4899+
assert!(error.contains("pass x/y explicitly"));
4900+
assert_eq!(params.x, None);
4901+
assert_eq!(params.y, None);
4902+
}
4903+
47764904
#[test]
47774905
fn relative_scroll_rejects_out_of_bounds() {
47784906
let mut params = ScrollParams {

0 commit comments

Comments
 (0)