feat: add keyboard command for raw keyboard input (#521)
Adds `keyboard type` and `keyboard insertText` subcommands that operate on the currently focused element without requiring a selector. Essential for contenteditable editors (Lexical, ProseMirror, CodeMirror, Monaco) where `type <selector>` doesn't trigger the editor's internal event pipeline (beforeinput/DOM mutation). - `keyboard type <text>` — page.keyboard.type() with real keystrokes - `keyboard insertText <text>` — page.keyboard.insertText() Note: `keyboard press` intentionally omitted — the existing top-level `press` command already operates on current focus. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,8 @@ agent-browser focus <sel> # Focus element
|
|||||||
agent-browser type <sel> <text> # Type into element
|
agent-browser type <sel> <text> # Type into element
|
||||||
agent-browser fill <sel> <text> # Clear and fill
|
agent-browser fill <sel> <text> # Clear and fill
|
||||||
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
||||||
|
agent-browser keyboard type <text> # Type with real keystrokes (no selector, current focus)
|
||||||
|
agent-browser keyboard inserttext <text> # Insert text without key events (no selector)
|
||||||
agent-browser keydown <key> # Hold key down
|
agent-browser keydown <key> # Hold key down
|
||||||
agent-browser keyup <key> # Release key
|
agent-browser keyup <key> # Release key
|
||||||
agent-browser hover <sel> # Hover element
|
agent-browser hover <sel> # Hover element
|
||||||
|
|||||||
@@ -263,6 +263,38 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
|
|||||||
})?;
|
})?;
|
||||||
Ok(json!({ "id": id, "action": "keyup", "key": key }))
|
Ok(json!({ "id": id, "action": "keyup", "key": key }))
|
||||||
}
|
}
|
||||||
|
"keyboard" => {
|
||||||
|
let sub = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||||
|
context: "keyboard".to_string(),
|
||||||
|
usage: "keyboard <type|inserttext> <text>",
|
||||||
|
})?;
|
||||||
|
match *sub {
|
||||||
|
"type" => {
|
||||||
|
let text: String = rest[1..].join(" ");
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(ParseError::MissingArguments {
|
||||||
|
context: "keyboard type".to_string(),
|
||||||
|
usage: "keyboard type <text>",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(json!({ "id": id, "action": "keyboard", "subaction": "type", "text": text }))
|
||||||
|
}
|
||||||
|
"inserttext" | "insertText" => {
|
||||||
|
let text: String = rest[1..].join(" ");
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err(ParseError::MissingArguments {
|
||||||
|
context: "keyboard inserttext".to_string(),
|
||||||
|
usage: "keyboard inserttext <text>",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(json!({ "id": id, "action": "keyboard", "subaction": "insertText", "text": text }))
|
||||||
|
}
|
||||||
|
_ => Err(ParseError::UnknownSubcommand {
|
||||||
|
subcommand: sub.to_string(),
|
||||||
|
valid_options: &["type", "inserttext"],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Scroll ===
|
// === Scroll ===
|
||||||
"scroll" => {
|
"scroll" => {
|
||||||
|
|||||||
@@ -713,6 +713,11 @@ Global Options:
|
|||||||
Examples:
|
Examples:
|
||||||
agent-browser type "#search" "hello"
|
agent-browser type "#search" "hello"
|
||||||
agent-browser type @e2 "additional text"
|
agent-browser type @e2 "additional text"
|
||||||
|
|
||||||
|
See Also:
|
||||||
|
For typing into contenteditable editors (Lexical, ProseMirror, etc.)
|
||||||
|
without a selector, use 'keyboard type' instead:
|
||||||
|
agent-browser keyboard type "# My Heading"
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
"hover" => {
|
"hover" => {
|
||||||
@@ -926,6 +931,42 @@ Examples:
|
|||||||
agent-browser keyup Control
|
agent-browser keyup Control
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
|
"keyboard" => {
|
||||||
|
r##"
|
||||||
|
agent-browser keyboard - Raw keyboard input (no selector needed)
|
||||||
|
|
||||||
|
Usage: agent-browser keyboard <subcommand> <text>
|
||||||
|
|
||||||
|
Sends keyboard input to whatever element currently has focus.
|
||||||
|
Unlike 'type' which requires a selector, 'keyboard' operates on
|
||||||
|
the current focus — essential for contenteditable editors like
|
||||||
|
Lexical, ProseMirror, CodeMirror, and Monaco.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
type <text> Type text character-by-character with real
|
||||||
|
key events (keydown, keypress, keyup per char)
|
||||||
|
inserttext <text> Insert text without key events (like paste)
|
||||||
|
|
||||||
|
Note: For key combos (Enter, Control+a), use the 'press' command
|
||||||
|
directly — it already operates on the current focus.
|
||||||
|
|
||||||
|
Global Options:
|
||||||
|
--json Output as JSON
|
||||||
|
--session <name> Use specific session
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
agent-browser keyboard type "Hello, World!"
|
||||||
|
agent-browser keyboard type "# My Heading"
|
||||||
|
agent-browser keyboard inserttext "pasted content"
|
||||||
|
|
||||||
|
Use Cases:
|
||||||
|
# Type into a Lexical/ProseMirror contenteditable editor:
|
||||||
|
agent-browser click "[contenteditable]"
|
||||||
|
agent-browser keyboard type "# My Heading"
|
||||||
|
agent-browser press Enter
|
||||||
|
agent-browser keyboard type "Some paragraph text"
|
||||||
|
"##
|
||||||
|
}
|
||||||
|
|
||||||
// === Scroll ===
|
// === Scroll ===
|
||||||
"scroll" => {
|
"scroll" => {
|
||||||
@@ -1958,6 +1999,8 @@ Core Commands:
|
|||||||
type <sel> <text> Type into element
|
type <sel> <text> Type into element
|
||||||
fill <sel> <text> Clear and fill
|
fill <sel> <text> Clear and fill
|
||||||
press <key> Press key (Enter, Tab, Control+a)
|
press <key> Press key (Enter, Tab, Control+a)
|
||||||
|
keyboard type <text> Type text with real keystrokes (no selector)
|
||||||
|
keyboard inserttext <text> Insert text without key events
|
||||||
hover <sel> Hover element
|
hover <sel> Hover element
|
||||||
focus <sel> Focus element
|
focus <sel> Focus element
|
||||||
check <sel> Check checkbox
|
check <sel> Check checkbox
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ agent-browser dblclick <sel> # Double-click
|
|||||||
agent-browser fill <sel> <text> # Clear and fill
|
agent-browser fill <sel> <text> # Clear and fill
|
||||||
agent-browser type <sel> <text> # Type into element
|
agent-browser type <sel> <text> # Type into element
|
||||||
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
agent-browser press <key> # Press key (Enter, Tab, Control+a) (alias: key)
|
||||||
|
agent-browser keyboard type <text> # Type at current focus (no selector needed)
|
||||||
|
agent-browser keyboard inserttext <text> # Insert text without key events
|
||||||
agent-browser keydown <key> # Hold key down
|
agent-browser keydown <key> # Hold key down
|
||||||
agent-browser keyup <key> # Release key
|
agent-browser keyup <key> # Release key
|
||||||
agent-browser hover <sel> # Hover element
|
agent-browser hover <sel> # Hover element
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ agent-browser type @e2 "text" # Type without clearing
|
|||||||
agent-browser select @e1 "option" # Select dropdown option
|
agent-browser select @e1 "option" # Select dropdown option
|
||||||
agent-browser check @e1 # Check checkbox
|
agent-browser check @e1 # Check checkbox
|
||||||
agent-browser press Enter # Press key
|
agent-browser press Enter # Press key
|
||||||
|
agent-browser keyboard type "text" # Type at current focus (no selector)
|
||||||
|
agent-browser keyboard inserttext "text" # Insert without key events
|
||||||
agent-browser scroll down 500 # Scroll page
|
agent-browser scroll down 500 # Scroll page
|
||||||
|
|
||||||
# Get information
|
# Get information
|
||||||
|
|||||||
+15
-2
@@ -1894,8 +1894,21 @@ async function handleKeyboard(
|
|||||||
browser: BrowserManager
|
browser: BrowserManager
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const page = browser.getPage();
|
const page = browser.getPage();
|
||||||
await page.keyboard.press(command.keys);
|
const sub = command.subaction ?? 'press';
|
||||||
return successResponse(command.id, { pressed: command.keys });
|
|
||||||
|
switch (sub) {
|
||||||
|
case 'type':
|
||||||
|
await page.keyboard.type(command.text ?? '', { delay: command.delay });
|
||||||
|
return successResponse(command.id, { typed: true, text: command.text });
|
||||||
|
case 'press':
|
||||||
|
await page.keyboard.press(command.keys ?? '');
|
||||||
|
return successResponse(command.id, { pressed: command.keys });
|
||||||
|
case 'insertText':
|
||||||
|
await page.keyboard.insertText(command.text ?? '');
|
||||||
|
return successResponse(command.id, { inserted: true, text: command.text });
|
||||||
|
default:
|
||||||
|
return errorResponse(command.id, `Unknown keyboard subaction: ${sub}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWheel(command: WheelCommand, browser: BrowserManager): Promise<Response> {
|
async function handleWheel(command: WheelCommand, browser: BrowserManager): Promise<Response> {
|
||||||
|
|||||||
+14
-1
@@ -442,7 +442,10 @@ const errorsSchema = baseCommandSchema.extend({
|
|||||||
|
|
||||||
const keyboardSchema = baseCommandSchema.extend({
|
const keyboardSchema = baseCommandSchema.extend({
|
||||||
action: z.literal('keyboard'),
|
action: z.literal('keyboard'),
|
||||||
keys: z.string().min(1),
|
subaction: z.enum(['type', 'press', 'insertText']).optional(),
|
||||||
|
keys: z.string().min(1).optional(),
|
||||||
|
text: z.string().min(1).optional(),
|
||||||
|
delay: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const wheelSchema = baseCommandSchema.extend({
|
const wheelSchema = baseCommandSchema.extend({
|
||||||
@@ -1058,6 +1061,16 @@ export function parseCommand(input: string): ParseResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.action === 'keyboard') {
|
||||||
|
const sub = command.subaction ?? 'press';
|
||||||
|
if ((sub === 'type' || sub === 'insertText') && !command.text) {
|
||||||
|
return { success: false, error: `keyboard ${sub} requires text`, id };
|
||||||
|
}
|
||||||
|
if (sub === 'press' && !command.keys) {
|
||||||
|
return { success: false, error: 'keyboard press requires keys', id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, command };
|
return { success: true, command };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -667,10 +667,13 @@ export interface ErrorsCommand extends BaseCommand {
|
|||||||
clear?: boolean;
|
clear?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Raw keyboard input (no selector needed)
|
||||||
export interface KeyboardCommand extends BaseCommand {
|
export interface KeyboardCommand extends BaseCommand {
|
||||||
action: 'keyboard';
|
action: 'keyboard';
|
||||||
keys: string; // e.g., "Control+a", "Shift+Tab"
|
subaction?: 'type' | 'press' | 'insertText'; // press kept for backward compat
|
||||||
|
keys?: string; // for legacy press path
|
||||||
|
text?: string; // for type/insertText
|
||||||
|
delay?: number; // for type (ms between keystrokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse wheel
|
// Mouse wheel
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseCommand } from '../src/protocol.js';
|
||||||
|
|
||||||
|
describe('keyboard command validation', () => {
|
||||||
|
it('accepts keyboard type with text', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type', text: 'hello' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts keyboard insertText with text', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'insertText', text: 'hello' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts keyboard press with keys', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'press', keys: 'Enter' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts legacy keyboard (no subaction) with keys', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', keys: 'Enter' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects keyboard type without text', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) expect(result.error).toContain('requires text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects keyboard insertText without text', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'insertText' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) expect(result.error).toContain('requires text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects keyboard press without keys', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'press' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) expect(result.error).toContain('requires keys');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects legacy keyboard (no subaction) without keys', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard' })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
if (!result.success) expect(result.error).toContain('requires keys');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts keyboard type with delay option', () => {
|
||||||
|
const result = parseCommand(
|
||||||
|
JSON.stringify({ id: '1', action: 'keyboard', subaction: 'type', text: 'hello', delay: 50 })
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user