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 =