44 * live page state.
55 *
66 * Perception drives through injected page functions (element registry with a
7- * structural outline); actuation is synthetic DOM events from those same
8- * functions. The user needs no input translation at all — the real page is
9- * embedded in the Sim window, so their clicks and typing are native. Tool
10- * calls serialize through a queue — one real browser can only do one thing at
11- * a time — and every call is bounded by a watchdog so the Sim side always
12- * gets a response instead of waiting out its own timeout against silence.
7+ * structural outline). Keyboard actuation (press_key, type) goes through
8+ * TRUSTED CDP input events — synthetic DOM KeyboardEvents never trigger
9+ * default editing actions (select-all, deletion, character insertion) and are
10+ * ignored by code editors, so they exist only as a fallback. Clicks still use
11+ * injected functions (element-targeted, no coordinate math). The user needs
12+ * no input translation at all — the real page is embedded in the Sim window,
13+ * so their clicks and typing are native. Tool calls serialize through a
14+ * queue — one real browser can only do one thing at a time — and every call
15+ * is bounded by a watchdog so the Sim side always gets a response instead of
16+ * waiting out its own timeout against silence.
1317 */
1418import type {
1519 BrowserPageState ,
@@ -23,12 +27,14 @@ import * as cdp from '@/main/browser-agent/cdp'
2327import {
2428 clickElement ,
2529 collectSnapshot ,
30+ focusElementForTyping ,
2631 getViewportInfo ,
2732 hasTakeoverBanner ,
2833 hoverElement ,
2934 isTakeoverDone ,
3035 pageContainsText ,
3136 pressKeyOnPage ,
37+ readActiveElementState ,
3238 readPageText ,
3339 removeTakeoverBanner ,
3440 scrollPage ,
@@ -338,6 +344,101 @@ export function parseKeyCombo(combo: string): ParsedCombo {
338344 throw new ToolError ( `Unrecognized key: "${ keyPart } "` )
339345}
340346
347+ // ---------------------------------------------------------------------------
348+ // Trusted key dispatch (CDP Input.dispatchKeyEvent)
349+ // ---------------------------------------------------------------------------
350+
351+ /** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */
352+ function cdpModifiers ( combo : ParsedCombo ) : number {
353+ return ( combo . alt ? 1 : 0 ) | ( combo . ctrl ? 2 : 0 ) | ( combo . meta ? 4 : 0 ) | ( combo . shift ? 8 : 0 )
354+ }
355+
356+ function editingCommandFor ( combo : ParsedCombo ) : string | null {
357+ switch ( combo . key . toLowerCase ( ) ) {
358+ case 'a' :
359+ return 'selectAll'
360+ case 'c' :
361+ return 'copy'
362+ case 'x' :
363+ return 'cut'
364+ case 'v' :
365+ return 'paste'
366+ case 'z' :
367+ return combo . shift ? 'redo' : 'undo'
368+ default :
369+ return null
370+ }
371+ }
372+
373+ /**
374+ * On macOS the editing shortcuts are bound in the system menu layer, which
375+ * CDP key events never traverse — so Blink must be told the editing command
376+ * explicitly (same technique as Puppeteer/Playwright). The model doesn't know
377+ * the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd
378+ * for these shortcuts: both must select all, not silently no-op. On other
379+ * platforms Ctrl+key is handled inside Blink and needs no help.
380+ */
381+ function normalizeComboForPlatform ( combo : ParsedCombo , platform : NodeJS . Platform ) : ParsedCombo {
382+ if ( platform !== 'darwin' || ! combo . ctrl || combo . meta || editingCommandFor ( combo ) === null ) {
383+ return combo
384+ }
385+ return { ...combo , ctrl : false , meta : true }
386+ }
387+
388+ function macEditingCommands ( combo : ParsedCombo , platform : NodeJS . Platform ) : string [ ] {
389+ if ( platform !== 'darwin' || ! combo . meta ) return [ ]
390+ const command = editingCommandFor ( combo )
391+ return command ? [ command ] : [ ]
392+ }
393+
394+ /**
395+ * Builds the trusted keyDown/keyUp pair for a combo. Printable keys without
396+ * ctrl/meta carry `text` so Blink inserts the character; Enter carries "\r"
397+ * so it activates defaults (form submission, newline). Everything else is a
398+ * rawKeyDown, which still drives Blink's default editing actions (Backspace
399+ * deletes, arrows move the caret, Ctrl/Cmd+A selects all).
400+ */
401+ export function buildKeyDispatchPlan (
402+ rawCombo : ParsedCombo ,
403+ platform : NodeJS . Platform = process . platform
404+ ) : [ cdp . CdpKeyEvent , cdp . CdpKeyEvent ] {
405+ const combo = normalizeComboForPlatform ( rawCombo , platform )
406+ const modifiers = cdpModifiers ( combo )
407+ const base = {
408+ modifiers,
409+ key : combo . key ,
410+ code : combo . code ,
411+ windowsVirtualKeyCode : combo . keyCode ,
412+ nativeVirtualKeyCode : combo . keyCode ,
413+ }
414+ const printable = combo . key . length === 1 && ! combo . ctrl && ! combo . meta
415+ const text = combo . key === 'Enter' ? '\r' : printable ? combo . key : undefined
416+ const commands = macEditingCommands ( combo , platform )
417+ const down : cdp . CdpKeyEvent = {
418+ ...base ,
419+ type : text !== undefined ? 'keyDown' : 'rawKeyDown' ,
420+ ...( text !== undefined ? { text } : { } ) ,
421+ ...( commands . length > 0 ? { commands } : { } ) ,
422+ }
423+ return [ down , { ...base , type : 'keyUp' } ]
424+ }
425+
426+ /** Presses a combo through the trusted pipeline. Throws on CDP failure. */
427+ async function dispatchKeyCombo ( contents : WebContents , combo : ParsedCombo ) : Promise < void > {
428+ const [ down , up ] = buildKeyDispatchPlan ( combo )
429+ await cdp . dispatchKeyEvent ( contents , down )
430+ await cdp . dispatchKeyEvent ( contents , up )
431+ }
432+
433+ /**
434+ * Post-action readback so the model sees the real effect (selection size,
435+ * value length) instead of assuming the key "worked".
436+ */
437+ async function activeElementState ( contents : WebContents ) : Promise < Record < string , unknown > > {
438+ const state = await execInPage ( contents , readActiveElementState , [ ] ) . catch ( ( ) => null )
439+ return typeof state === 'object' && state !== null ? ( state as Record < string , unknown > ) : { }
440+ }
441+
341442// ---------------------------------------------------------------------------
342443// Takeover
343444// ---------------------------------------------------------------------------
@@ -504,28 +605,62 @@ async function executeToolInner(
504605 }
505606
506607 case 'browser_type' : {
608+ const elementId = requireNum ( params , 'elementId' )
609+ const text = requireStr ( params , 'text' )
610+ const submit = params . submit === true
507611 const contents = session . requireTab ( ) . view . webContents
508- return unwrapPageResult (
509- await execInPage ( contents , typeIntoElement , [
510- requireNum ( params , 'elementId' ) ,
511- requireStr ( params , 'text' ) ,
512- params . submit === true ,
513- ] )
514- )
612+
613+ // Native path: focus + select current content, then insert through the
614+ // IME pipeline so the text REPLACES what's there — the only write path
615+ // code editors (CodeMirror/Monaco) honor. The DOM selection set by the
616+ // page function covers plain fields; the real select-all keystroke
617+ // right after covers editors that track selection in their own model
618+ // (their keymaps handle it synchronously, where DOM-selection sync is
619+ // async and can lose a race with the insert). Falls back to the
620+ // synthetic value-setter when CDP is unavailable.
621+ unwrapPageResult ( await execInPage ( contents , focusElementForTyping , [ elementId ] ) )
622+ try {
623+ await dispatchKeyCombo (
624+ contents ,
625+ parseKeyCombo ( process . platform === 'darwin' ? 'Cmd+A' : 'Control+A' )
626+ )
627+ await cdp . insertText ( contents , text )
628+ } catch {
629+ return unwrapPageResult (
630+ await execInPage ( contents , typeIntoElement , [ elementId , text , submit ] )
631+ )
632+ }
633+ if ( submit ) {
634+ await dispatchKeyCombo ( contents , parseKeyCombo ( 'Enter' ) ) . catch ( ( ) => { } )
635+ }
636+ const state = await activeElementState ( contents )
637+ return { typed : true , replacedExisting : true , submitted : submit , ...state }
515638 }
516639
517640 case 'browser_press_key' : {
518641 const combo = parseKeyCombo ( requireStr ( params , 'key' ) )
519642 const contents = session . requireTab ( ) . view . webContents
520- return await execInPage ( contents , pressKeyOnPage , [
521- combo . key ,
522- combo . code ,
523- combo . keyCode ,
524- combo . ctrl ,
525- combo . meta ,
526- combo . shift ,
527- combo . alt ,
528- ] )
643+ try {
644+ await dispatchKeyCombo ( contents , combo )
645+ } catch {
646+ // CDP unavailable (debugger detached): synthetic DOM fallback. It
647+ // cannot trigger default editing actions, so say so in the result.
648+ const fallback = await execInPage ( contents , pressKeyOnPage , [
649+ combo . key ,
650+ combo . code ,
651+ combo . keyCode ,
652+ combo . ctrl ,
653+ combo . meta ,
654+ combo . shift ,
655+ combo . alt ,
656+ ] )
657+ return {
658+ ...( typeof fallback === 'object' && fallback !== null ? fallback : { } ) ,
659+ note : 'Delivered as a synthetic page event; editing shortcuts may not take effect.' ,
660+ }
661+ }
662+ const state = await activeElementState ( contents )
663+ return { pressed : requireStr ( params , 'key' ) , ...state }
529664 }
530665
531666 case 'browser_scroll' : {
0 commit comments