feat: add screenshot output config, clipboard CLI commands, and fix wait --text native path (#749)

* feat: add screenshot output config, clipboard CLI commands, and fix wait --text native path

## Summary

- Add `--screenshot-dir`, `--screenshot-quality`, and `--screenshot-format` CLI flags (with corresponding `AGENT_BROWSER_SCREENSHOT_DIR`, `AGENT_BROWSER_SCREENSHOT_QUALITY`, `AGENT_BROWSER_SCREENSHOT_FORMAT` env vars) so users can configure where and how screenshots are saved without specifying a full path every time
- Add `clipboard read`, `clipboard write <text>`, `clipboard copy`, and `clipboard paste` CLI commands, exposing the existing protocol-level clipboard handlers that were previously only accessible via JSON-RPC
- Fix `wait --text` in native mode: the CLI was emitting `selector: "text=..."` (a Playwright-style locator) which native's `querySelector` can't handle. Now emits a `text` field that correctly hits the native `wait_for_text` polling path
- Add native clipboard `copy` and `paste` support via CDP `Input.dispatchKeyEvent`, and a `write` operation to the Node.js handler

* fix: resolve CI failures in Rust formatting and TypeScript typecheck

Use string-based page.evaluate for clipboard writeText to avoid
referencing `navigator` in Node.js compilation context. Run cargo fmt
to fix formatting in commands.rs and screenshot.rs.

* fix: clipboard write captures full multi-word text

Use rest[1..].join(" ") instead of rest.get(1) so unquoted multi-word
input like `clipboard write hello world` sends the full string rather
than silently dropping everything after the first word.

* improvements

* fixes

* improvements

* improvements
This commit is contained in:
Chris Tate
2026-03-13 02:58:30 -05:00
committed by GitHub
parent 1129a3e7fc
commit a673a77c4e
12 changed files with 369 additions and 32 deletions
+27 -7
View File
@@ -1415,6 +1415,10 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
.and_then(|v| v.as_i64())
.map(|q| q as i32),
annotate,
output_dir: cmd
.get("screenshotDir")
.and_then(|v| v.as_str())
.map(String::from),
};
if annotate {
@@ -1641,6 +1645,11 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
let session_id = mgr.active_session_id()?.to_string();
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
if let Some(text) = cmd.get("text").and_then(|v| v.as_str()) {
wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?;
return Ok(json!({ "waited": "text", "text": text }));
}
if let Some(selector) = cmd.get("selector").and_then(|v| v.as_str()) {
let state_str = cmd
.get("state")
@@ -1655,11 +1664,6 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
return Ok(json!({ "waited": "url", "url": url_pattern }));
}
if let Some(text) = cmd.get("text").and_then(|v| v.as_str()) {
wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?;
return Ok(json!({ "waited": "text", "text": text }));
}
if let Some(fn_str) = cmd.get("function").and_then(|v| v.as_str()) {
wait_for_function(&mgr.client, &session_id, fn_str, timeout_ms).await?;
return Ok(json!({ "waited": "function" }));
@@ -3107,8 +3111,13 @@ async fn handle_clipboard(cmd: &Value, state: &DaemonState) -> Result<Value, Str
.and_then(|v| v.as_str())
.unwrap_or("read");
let session_id = mgr.active_session_id()?.to_string();
// cfg! is compile-time; assumes the browser runs on the same OS as the CLI binary.
let modifier: i32 = if cfg!(target_os = "macos") { 4 } else { 2 };
match action {
"write" | "copy" => {
"write" => {
let text = cmd
.get("text")
.or_else(|| cmd.get("value"))
@@ -3119,7 +3128,17 @@ async fn handle_clipboard(cmd: &Value, state: &DaemonState) -> Result<Value, Str
serde_json::to_string(text).unwrap_or_default()
);
mgr.evaluate(&js, None).await?;
Ok(json!({ "copied": text }))
Ok(json!({ "written": text }))
}
"copy" => {
interaction::press_key_with_modifiers(&mgr.client, &session_id, "c", Some(modifier))
.await?;
Ok(json!({ "copied": true }))
}
"paste" => {
interaction::press_key_with_modifiers(&mgr.client, &session_id, "v", Some(modifier))
.await?;
Ok(json!({ "pasted": true }))
}
_ => {
let result = mgr.evaluate("navigator.clipboard.readText()", None).await?;
@@ -4190,6 +4209,7 @@ async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Valu
format: "png".to_string(),
quality: None,
annotate: false,
output_dir: None,
};
let result =
+18 -2
View File
@@ -206,6 +206,22 @@ pub async fn type_text(
}
pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
press_key_with_modifiers(client, session_id, key, None).await
}
/// Dispatch a keyDown+keyUp sequence for `key` with an optional CDP modifier bitmask.
///
/// Modifier values follow the CDP `Input.dispatchKeyEvent` spec:
/// 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
///
/// Callers that need a platform-appropriate modifier (e.g. Cmd on macOS,
/// Ctrl elsewhere) must choose the value themselves -- see `cfg!(target_os)`.
pub async fn press_key_with_modifiers(
client: &CdpClient,
session_id: &str,
key: &str,
modifiers: Option<i32>,
) -> Result<(), String> {
let (key_name, code, key_code) = named_key_info(key);
client
@@ -219,7 +235,7 @@ pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Resul
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
modifiers,
},
Some(session_id),
)
@@ -236,7 +252,7 @@ pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Resul
unmodified_text: None,
windows_virtual_key_code: Some(key_code),
native_virtual_key_code: Some(key_code),
modifiers: None,
modifiers,
},
Some(session_id),
)
+13 -2
View File
@@ -57,6 +57,7 @@ pub struct ScreenshotOptions {
pub format: String,
pub quality: Option<i32>,
pub annotate: bool,
pub output_dir: Option<String>,
}
impl Default for ScreenshotOptions {
@@ -68,6 +69,7 @@ impl Default for ScreenshotOptions {
format: "png".to_string(),
quality: None,
annotate: false,
output_dir: None,
}
}
}
@@ -145,7 +147,12 @@ pub async fn take_screenshot(
} else {
"png"
};
let path = save_screenshot(&base64, options.path.as_deref(), ext)?;
let path = save_screenshot(
&base64,
options.path.as_deref(),
ext,
options.output_dir.as_deref(),
)?;
Ok(ScreenshotResult {
path,
@@ -479,11 +486,15 @@ fn save_screenshot(
base64_data: &str,
explicit_path: Option<&str>,
ext: &str,
output_dir: Option<&str>,
) -> Result<String, String> {
let save_path = match explicit_path {
Some(path) => path.to_string(),
None => {
let dir = get_screenshot_dir();
let dir = match output_dir {
Some(d) => PathBuf::from(d),
None => get_screenshot_dir(),
};
let _ = std::fs::create_dir_all(&dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)