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
+19 -6
View File
@@ -741,7 +741,7 @@ async function handleScreenshot(
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const random = Math.random().toString(36).substring(2, 8);
const filename = `screenshot-${timestamp}-${random}.${ext}`;
const screenshotDir = path.join(getAppDir(), 'tmp', 'screenshots');
const screenshotDir = command.screenshotDir ?? path.join(getAppDir(), 'tmp', 'screenshots');
mkdirSync(screenshotDir, { recursive: true });
savePath = path.join(screenshotDir, filename);
}
@@ -954,7 +954,13 @@ async function handleEvaluate(
async function handleWait(command: WaitCommand, browser: BrowserManager): Promise<Response> {
const page = browser.getPage();
if (command.selector) {
if (command.text) {
await page.waitForFunction(
(t: string) => (document.body.innerText || '').includes(t),
command.text,
{ timeout: command.timeout }
);
} else if (command.selector) {
await page.waitForSelector(command.selector, {
state: command.state ?? 'visible',
timeout: command.timeout,
@@ -962,7 +968,6 @@ async function handleWait(command: WaitCommand, browser: BrowserManager): Promis
} else if (command.timeout) {
await page.waitForTimeout(command.timeout);
} else {
// Default: wait for load state
await page.waitForLoadState('load');
}
@@ -2119,14 +2124,22 @@ async function handleClipboard(
switch (command.operation) {
case 'copy':
await page.keyboard.press('Control+c');
await page.keyboard.press('ControlOrMeta+c');
return successResponse(command.id, { copied: true });
case 'paste':
await page.keyboard.press('Control+v');
await page.keyboard.press('ControlOrMeta+v');
return successResponse(command.id, { pasted: true });
case 'read':
case 'read': {
const text = await page.evaluate('navigator.clipboard.readText()');
return successResponse(command.id, { text });
}
case 'write': {
if (!command.text) {
return errorResponse(command.id, "Missing 'text' parameter for clipboard write");
}
await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(command.text)})`);
return successResponse(command.id, { written: command.text });
}
default:
return errorResponse(command.id, 'Unknown clipboard operation');
}
+3 -1
View File
@@ -468,7 +468,7 @@ const tapSchema = baseCommandSchema.extend({
const clipboardSchema = baseCommandSchema.extend({
action: z.literal('clipboard'),
operation: z.enum(['copy', 'paste', 'read']),
operation: z.enum(['copy', 'paste', 'read', 'write']),
text: z.string().optional(),
});
@@ -794,6 +794,7 @@ const screenshotSchema = baseCommandSchema.extend({
format: z.enum(['png', 'jpeg']).optional(),
quality: z.number().min(0).max(100).optional(),
annotate: z.boolean().optional(),
screenshotDir: z.string().optional(),
});
const snapshotSchema = baseCommandSchema.extend({
@@ -814,6 +815,7 @@ const evaluateSchema = baseCommandSchema.extend({
const waitSchema = baseCommandSchema.extend({
action: z.literal('wait'),
selector: z.string().min(1).optional(),
text: z.string().min(1).optional(),
timeout: z.number().positive().optional(),
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
});
+3 -1
View File
@@ -707,7 +707,7 @@ export interface TapCommand extends BaseCommand {
// Clipboard
export interface ClipboardCommand extends BaseCommand {
action: 'clipboard';
operation: 'copy' | 'paste' | 'read';
operation: 'copy' | 'paste' | 'read' | 'write';
text?: string;
}
@@ -826,6 +826,7 @@ export interface ScreenshotCommand extends BaseCommand {
format?: 'png' | 'jpeg';
quality?: number;
annotate?: boolean;
screenshotDir?: string;
}
export interface SnapshotCommand extends BaseCommand {
@@ -841,6 +842,7 @@ export interface EvaluateCommand extends BaseCommand {
export interface WaitCommand extends BaseCommand {
action: 'wait';
selector?: string;
text?: string;
timeout?: number;
state?: 'attached' | 'detached' | 'visible' | 'hidden';
}