Skip to content
4 changes: 3 additions & 1 deletion libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ pub fn default_capabilities_for(tool_name: &str) -> Vec<String> {
"screen.capture.region",
],
"get_screen_size" => &["screen.dimensions"],
"get_desktop_state" => &["screen.capture", "screen.dimensions"],
"get_cursor_position" => &["screen.cursor.position"],

// ── accessibility / window state ─────────────────────────────
Expand Down Expand Up @@ -520,7 +521,8 @@ mod capability_tests {
// keyboard
"type_text", "type_text_chars", "press_key", "hotkey", "set_value",
// screen
"zoom", "get_screen_size", "get_cursor_position",
"zoom", "get_screen_size", "get_desktop_state",
"get_cursor_position",
// accessibility
"get_accessibility_tree", "get_window_state",
// app / window
Expand Down
172 changes: 147 additions & 25 deletions libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ use cursor_overlay::CursorRegistry;
#[derive(Clone)]
pub struct DriverConfig {
pub capture_mode: String,
pub capture_scope: String,
pub max_image_dimension: u32,
}

impl Default for DriverConfig {
fn default() -> Self { Self { capture_mode: "som".into(), max_image_dimension: 1568 } }
fn default() -> Self { Self { capture_mode: "som".into(), capture_scope: "window".into(), max_image_dimension: 1568 } }
}

/// Load `DriverConfig` from `~/.cua-driver/config.json`, falling back to
Expand All @@ -36,6 +37,9 @@ pub fn load_driver_config() -> DriverConfig {
if let Some(v) = pip_preview::read_config_value("capture_mode").and_then(|v| v.as_str().map(str::to_owned)) {
cfg.capture_mode = v;
}
if let Some(v) = pip_preview::read_config_value("capture_scope").and_then(|v| v.as_str().map(str::to_owned)) {
cfg.capture_scope = v;
}
if let Some(v) = pip_preview::read_config_value("max_image_dimension").and_then(|v| v.as_u64()) {
if let Ok(v32) = u32::try_from(v) { cfg.max_image_dimension = v32; }
}
Expand Down Expand Up @@ -2872,33 +2876,11 @@ impl Tool for GetScreenSizeTool {
}
async fn invoke(&self, _args: Value) -> ToolResult {
let result = tokio::task::spawn_blocking(|| {
use x11rb::connection::Connection;
use x11rb::rust_connection::RustConnection;
let (conn, screen_num) = RustConnection::connect(None)
.map_err(|e| anyhow::anyhow!("{e}{}", crate::no_display_hint()))?;
let setup = conn.setup();
let screen = &setup.roots[screen_num];
let w = screen.width_in_pixels as u32;
let h = screen.height_in_pixels as u32;
// WSLg / headless XWayland quirk: the X server connects but the
// root screen advertises a 0-px geometry until a real output is
// attached. Returning {width:0,height:0} here would propagate a
// success with zero dimensions to the client, which then either
// divides by zero when scaling or feeds the value into `int(...)`
// after the missing key collapses to None. Fail loudly with an
// actionable, typed error instead (never emit a 0/null where the
// client expects a usable int). See issue #2005.
if w == 0 || h == 0 {
anyhow::bail!(
"X11 connected but reports a 0x0 root screen — no usable \
display geometry.{}",
crate::no_display_hint()
);
}
// X11 reports pixel dimensions; scale factor on X11 is not
// well-defined per-monitor, so report 1.0 (matches DPI-unaware
// assumption). Wayland/HiDPI X11 callers should query
// `xrandr --query` for true scale.
let (w, h) = x11_screen_size()?;
Ok::<(u32, u32, f64), anyhow::Error>((w, h, 1.0))
}).await;
match result {
Expand All @@ -2911,6 +2893,111 @@ impl Tool for GetScreenSizeTool {
}
}

/// Read the true X11 root-window size in pixels: (width, height).
/// Shared by `get_screen_size` and `get_desktop_state`.
fn x11_screen_size() -> anyhow::Result<(u32, u32)> {
use x11rb::connection::Connection;
use x11rb::rust_connection::RustConnection;
let (conn, screen_num) = RustConnection::connect(None)
.map_err(|e| anyhow::anyhow!("{e}{}", crate::no_display_hint()))?;
let setup = conn.setup();
let screen = &setup.roots[screen_num];
let w = screen.width_in_pixels as u32;
let h = screen.height_in_pixels as u32;
// WSLg / headless XWayland quirk: the X server connects but the
// root screen advertises a 0-px geometry until a real output is
// attached. Returning {width:0,height:0} here would propagate a
// success with zero dimensions to the client, which then either
// divides by zero when scaling or feeds the value into `int(...)`
// after the missing key collapses to None. Fail loudly with an
// actionable, typed error instead (never emit a 0/null where the
// client expects a usable int). See issue #2005.
if w == 0 || h == 0 {
anyhow::bail!(
"X11 connected but reports a 0x0 root screen — no usable \
display geometry.{}",
crate::no_display_hint()
);
}
Ok((w, h))
}

// ── get_desktop_state ─────────────────────────────────────────────────────────

pub struct GetDesktopStateTool;
static GDS_DEF: std::sync::OnceLock<ToolDef> = std::sync::OnceLock::new();

#[async_trait]
impl Tool for GetDesktopStateTool {
fn def(&self) -> &ToolDef {
GDS_DEF.get_or_init(|| ToolDef {
name: "get_desktop_state".into(),
description: "Full-display vision screenshot in true screen pixels (no downscale), \
for capture_scope=\"desktop\" GUI loops. Captures the entire display (root \
window) as native-size PNG so screen-absolute pixel coordinates land exactly. \
No AT-SPI walk.".into(),
input_schema: json!({"type":"object","properties":{
"session":{"type":"string","description":"Optional session id."},
"screenshot_out_file":{"type":"string","description":"Write PNG here instead of base64."}
},"additionalProperties":false}),
read_only: true, destructive: false, idempotent: false, open_world: false,
})
}

async fn invoke(&self, args: Value) -> ToolResult {
let out_file = args.opt_str("screenshot_out_file");

let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
// Vision-only: capture the FULL DISPLAY at native size. No downscale
// so screen-absolute pixels land exactly.
let png = crate::capture::screenshot_display_bytes()?;
let (shot_w, shot_h) = crate::capture::png_dimensions_pub(&png)?;
// True screen size from the X11 root window.
let (screen_w, screen_h) = x11_screen_size()?;
// Optional: write PNG to disk instead of returning base64.
let written = if let Some(path) = out_file.as_deref() {
std::fs::write(path, &png)?;
Some(path.to_string())
Comment on lines +2948 to +2960

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Expand ~/ before writing screenshot_out_file.

Linux currently writes "~/foo.png" literally, while the peer macOS desktop-state tool expands the home prefix. This makes cross-platform callers fail on Linux for the same output path.

Proposed fix
-        let out_file = args.opt_str("screenshot_out_file");
+        let out_file = args.opt_str("screenshot_out_file").map(|s| {
+            if let Some(rest) = s.strip_prefix("~/") {
+                if let Ok(home) = std::env::var("HOME") {
+                    return format!("{home}/{rest}");
+                }
+            }
+            s
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let out_file = args.opt_str("screenshot_out_file");
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
// Vision-only: capture the FULL DISPLAY at native size. No downscale
// so screen-absolute pixels land exactly.
let png = crate::capture::screenshot_display_bytes()?;
let (shot_w, shot_h) = crate::capture::png_dimensions_pub(&png)?;
// True screen size from the X11 root window.
let (screen_w, screen_h) = x11_screen_size()?;
// Optional: write PNG to disk instead of returning base64.
let written = if let Some(path) = out_file.as_deref() {
std::fs::write(path, &png)?;
Some(path.to_string())
let out_file = args.opt_str("screenshot_out_file").map(|s| {
if let Some(rest) = s.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}/{rest}");
}
}
s
});
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
// Vision-only: capture the FULL DISPLAY at native size. No downscale
// so screen-absolute pixels land exactly.
let png = crate::capture::screenshot_display_bytes()?;
let (shot_w, shot_h) = crate::capture::png_dimensions_pub(&png)?;
// True screen size from the X11 root window.
let (screen_w, screen_h) = x11_screen_size()?;
// Optional: write PNG to disk instead of returning base64.
let written = if let Some(path) = out_file.as_deref() {
std::fs::write(path, &png)?;
Some(path.to_string())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs` around lines
2755 - 2767, The screenshot output path handling in the Linux tool does not
expand a leading home prefix, so `screenshot_out_file` can be written literally
as `~/...` instead of the user’s home directory. Update the `out_file` handling
inside the `spawn_blocking` block to normalize the provided path before
`std::fs::write`, expanding `~/` the same way the macOS desktop-state tool does.
Use the existing `screenshot_out_file` argument flow and the
`out_file.as_deref()` branch to apply the expansion before writing or returning
the path.

} else {
None
};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let b64 = if written.is_some() { None } else { Some(B64.encode(&png)) };
Ok((b64, shot_w, shot_h, screen_w, screen_h, written))
}).await;

match result {
Ok(Ok((b64_opt, shot_w, shot_h, screen_w, screen_h, written))) => {
let mut content = Vec::new();
let mut structured = json!({
"platform": "linux",
"screenshot_width": shot_w,
"screenshot_height": shot_h,
"screen_width": screen_w,
"screen_height": screen_h,
"screenshot_mime_type": "image/png",
});
Comment on lines +2972 to +2979

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Include scale_factor in desktop structured content.

get_desktop_state should expose the same geometry contract as the peer platforms; Linux already reports 1.0 from get_screen_size, but omits it here. Clients normalizing desktop screenshots across platforms can’t rely on the field.

Proposed fix
                 let mut structured = json!({
                     "platform": "linux",
                     "screenshot_width": shot_w,
                     "screenshot_height": shot_h,
                     "screen_width": screen_w,
                     "screen_height": screen_h,
+                    "scale_factor": 1.0,
                     "screenshot_mime_type": "image/png",
                 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let mut structured = json!({
"platform": "linux",
"screenshot_width": shot_w,
"screenshot_height": shot_h,
"screen_width": screen_w,
"screen_height": screen_h,
"screenshot_mime_type": "image/png",
});
let mut structured = json!({
"platform": "linux",
"screenshot_width": shot_w,
"screenshot_height": shot_h,
"screen_width": screen_w,
"screen_height": screen_h,
"scale_factor": 1.0,
"screenshot_mime_type": "image/png",
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs` around lines
2779 - 2786, The Linux desktop structured payload in get_desktop_state is
missing the scale_factor field that peer platforms include. Update the
structured JSON built in the get_desktop_state path to add scale_factor, using
the same value already reported by get_screen_size on Linux (1.0), so clients
can rely on a consistent geometry contract across platforms.

if let Some(b64) = b64_opt {
content.push(cua_driver_core::protocol::Content::image_png(b64));
}
if let Some(path) = written {
structured["screenshot_file_path"] = json!(path);
content.push(cua_driver_core::protocol::Content::text(format!(
"✅ Desktop screenshot {shot_w}x{shot_h} written to {path} (screen {screen_w}x{screen_h})"
)));
} else {
content.push(cua_driver_core::protocol::Content::text(format!(
"✅ Desktop screenshot {shot_w}x{shot_h} (screen {screen_w}x{screen_h})"
)));
}
ToolResult { content, is_error: None, structured_content: Some(structured) }
}
Ok(Err(e)) => ToolResult::error(format!("Capture error: {e}")),
Err(e) => ToolResult::error(format!("Task error: {e}")),
}
}
}

// ── get_cursor_position ───────────────────────────────────────────────────────

pub struct GetCursorPositionTool;
Expand Down Expand Up @@ -3373,6 +3460,7 @@ impl Tool for GetConfigTool {
"version": env!("CARGO_PKG_VERSION"),
"platform": "linux",
"capture_mode": cfg.capture_mode,
"capture_scope": cfg.capture_scope,
"max_image_dimension": cfg.max_image_dimension,
"experimental_pip": pip_enabled,
"experimental_pip_geometry": pip_geometry
Expand Down Expand Up @@ -3405,6 +3493,7 @@ impl Tool for SetConfigTool {
"key":{"type":"string","description":"Name of a single config field to write ({key, value} shape). Pair with `value`."},
"value":{"description":"New value for `key`. JSON type depends on the key."},
"capture_mode":{"type":"string","enum":["som","vision","ax"],"description":"Legacy per-field shape. Default capture mode for get_window_state."},
"capture_scope":{"type":"string","enum":["window","desktop"],"description":"Capture scope: single window or whole desktop. Default window."},
"max_image_dimension":{"type":"integer","description":"Legacy per-field shape. Max dimension for screenshot resizing (0 = no limit)."},
"experimental_pip":{"type":"boolean","description":"Enable the experimental PiP preview window (applies next restart; Linux backend stubbed)."},
"experimental_pip_geometry":{"type":"string","description":"PiP window size + optional position in `WxH` or `WxH+X+Y` form."}
Expand Down Expand Up @@ -3435,6 +3524,17 @@ impl Tool for SetConfigTool {
}
None => return ToolResult::error(format!("`capture_mode` must be a string, got {val}.")),
},
"capture_scope" => match val.as_str() {
Some(s @ ("window" | "desktop")) => {
cfg.capture_scope = s.to_owned();
if let Err(e) = pip_preview::write_config_key("capture_scope", Value::String(s.to_owned())) {
tracing::warn!("set_config: failed to persist capture_scope: {e}");
}
parts.push(format!("capture_scope={s}"));
}
Some(other) => return ToolResult::error(format!("`capture_scope` must be \"window\" or \"desktop\", got \"{other}\".")),
None => return ToolResult::error(format!("`capture_scope` must be a string, got {val}.")),
},
Comment on lines +3527 to +3537

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep capture_scope session-scoped when a session is present.

Both new capture_scope branches mutate the shared DriverConfig, so a session enabling "desktop" can leak into other Linux sessions. The PR contract and macOS peer implementation treat this as a session override when _session_id is present.

Also applies to: 3340-3346

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs` around lines
3298 - 3302, The new capture_scope handling in the Linux tool parser is mutating
the shared DriverConfig, which can leak a session’s “desktop” choice into other
sessions; update the capture_scope branches in impl_ to treat it as a
session-only override whenever _session_id is present, matching the macOS peer
behavior. Use the existing capture_scope match logic and the session-related
state in the same tool parsing flow to apply the override locally rather than
writing it back to the shared config, and make the same change in the other
capture_scope branch around the later matching block.

"max_image_dimension" => match val.as_u64() {
Some(n) => {
cfg.max_image_dimension = n as u32;
Expand Down Expand Up @@ -3469,7 +3569,7 @@ impl Tool for SetConfigTool {
None => return ToolResult::error(format!("`experimental_pip_geometry` must be a string, got {val}.")),
},
other => return ToolResult::error(format!(
"Unknown config key `{other}`. Known: capture_mode, max_image_dimension, experimental_pip, experimental_pip_geometry."
"Unknown config key `{other}`. Known: capture_mode, capture_scope, max_image_dimension, experimental_pip, experimental_pip_geometry."
)),
}
}
Expand All @@ -3481,6 +3581,16 @@ impl Tool for SetConfigTool {
parts.push(format!("capture_mode={mode}"));
cfg.capture_mode = mode;
}
if let Some(scope) = args.opt_str("capture_scope") {
if scope != "window" && scope != "desktop" {
return ToolResult::error(format!("`capture_scope` must be \"window\" or \"desktop\", got \"{scope}\"."));
}
if let Err(e) = pip_preview::write_config_key("capture_scope", Value::String(scope.clone())) {
tracing::warn!("set_config: failed to persist capture_scope: {e}");
}
parts.push(format!("capture_scope={scope}"));
cfg.capture_scope = scope;
}
if let Some(dim) = args.opt_u64("max_image_dimension") {
cfg.max_image_dimension = dim as u32;
if let Err(e) = pip_preview::write_config_key("max_image_dimension", Value::from(dim)) {
Expand Down Expand Up @@ -3514,6 +3624,7 @@ impl Tool for SetConfigTool {
ToolResult::text(msg)
.with_structured(json!({
"capture_mode": cfg.capture_mode,
"capture_scope": cfg.capture_scope,
"max_image_dimension": cfg.max_image_dimension,
"experimental_pip": pip_enabled,
"experimental_pip_geometry": pip_geometry
Expand Down Expand Up @@ -3867,6 +3978,7 @@ pub fn build_registry(compat: bool) -> ToolRegistry {
// screenshot path is `get_window_state` with `capture_mode:"vision"`.
let _ = compat;
r.register(Box::new(GetScreenSizeTool));
r.register(Box::new(GetDesktopStateTool));
r.register(Box::new(GetCursorPositionTool));
r.register(Box::new(MoveCursorTool { state: state.clone() }));
r.register(Box::new(SetAgentCursorEnabledTool { state: state.clone() }));
Expand Down Expand Up @@ -3930,3 +4042,13 @@ mod click_button_schema_tests {
assert!(lc.contains("wayland"), "description should call out wayland fallback");
}
}

#[cfg(test)]
mod driver_config_tests {
use super::DriverConfig;

#[test]
fn capture_scope_defaults_to_window() {
assert_eq!(DriverConfig::default().capture_scope, "window");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ impl Tool for GetConfigTool {
// sees its own override layered over the global; the anonymous session
// (absent `_session_id`) sees the raw global — today's behavior.
let session_id = args.opt_str("_session_id");
let (capture_mode, max_image_dimension) = {
let (capture_mode, max_image_dimension, capture_scope) = {
let cfg = self.state.config.read().unwrap();
self.state.session_config.effective(session_id.as_deref(), &cfg)
let (mode, dim) = self.state.session_config.effective(session_id.as_deref(), &cfg);
let scope = self.state.session_config.effective_scope(session_id.as_deref(), &cfg);
(mode, dim, scope)
};
// Report the CALLING session's own cursor enabled-state, not a
// nondeterministic HashMap.first(). Resolve the same key the click /
Expand All @@ -61,6 +63,7 @@ impl Tool for GetConfigTool {
"version": env!("CARGO_PKG_VERSION"),
"platform": "macos",
"capture_mode": capture_mode,
"capture_scope": capture_scope,
"max_image_dimension": max_image_dimension,
"agent_cursor": {
"enabled": cursor_enabled,
Expand Down
Loading
Loading