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,10 +27,12 @@ import * as cdp from '@/main/browser-agent/cdp'
2327import {
2428 clickElement ,
2529 collectSnapshot ,
30+ focusElementForTyping ,
2631 getViewportInfo ,
2732 hoverElement ,
2833 pageContainsText ,
2934 pressKeyOnPage ,
35+ readActiveElementState ,
3036 readPageText ,
3137 scrollPage ,
3238 selectOptionInElement ,
@@ -343,6 +349,101 @@ export function parseKeyCombo(combo: string): ParsedCombo {
343349 throw new ToolError ( `Unrecognized key: "${ keyPart } "` )
344350}
345351
352+ // ---------------------------------------------------------------------------
353+ // Trusted key dispatch (CDP Input.dispatchKeyEvent)
354+ // ---------------------------------------------------------------------------
355+
356+ /** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */
357+ function cdpModifiers ( combo : ParsedCombo ) : number {
358+ return ( combo . alt ? 1 : 0 ) | ( combo . ctrl ? 2 : 0 ) | ( combo . meta ? 4 : 0 ) | ( combo . shift ? 8 : 0 )
359+ }
360+
361+ function editingCommandFor ( combo : ParsedCombo ) : string | null {
362+ switch ( combo . key . toLowerCase ( ) ) {
363+ case 'a' :
364+ return 'selectAll'
365+ case 'c' :
366+ return 'copy'
367+ case 'x' :
368+ return 'cut'
369+ case 'v' :
370+ return 'paste'
371+ case 'z' :
372+ return combo . shift ? 'redo' : 'undo'
373+ default :
374+ return null
375+ }
376+ }
377+
378+ /**
379+ * On macOS the editing shortcuts are bound in the system menu layer, which
380+ * CDP key events never traverse — so Blink must be told the editing command
381+ * explicitly (same technique as Puppeteer/Playwright). The model doesn't know
382+ * the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd
383+ * for these shortcuts: both must select all, not silently no-op. On other
384+ * platforms Ctrl+key is handled inside Blink and needs no help.
385+ */
386+ function normalizeComboForPlatform ( combo : ParsedCombo , platform : NodeJS . Platform ) : ParsedCombo {
387+ if ( platform !== 'darwin' || ! combo . ctrl || combo . meta || editingCommandFor ( combo ) === null ) {
388+ return combo
389+ }
390+ return { ...combo , ctrl : false , meta : true }
391+ }
392+
393+ function macEditingCommands ( combo : ParsedCombo , platform : NodeJS . Platform ) : string [ ] {
394+ if ( platform !== 'darwin' || ! combo . meta ) return [ ]
395+ const command = editingCommandFor ( combo )
396+ return command ? [ command ] : [ ]
397+ }
398+
399+ /**
400+ * Builds the trusted keyDown/keyUp pair for a combo. Printable keys without
401+ * ctrl/meta carry `text` so Blink inserts the character; Enter carries "\r"
402+ * so it activates defaults (form submission, newline). Everything else is a
403+ * rawKeyDown, which still drives Blink's default editing actions (Backspace
404+ * deletes, arrows move the caret, Ctrl/Cmd+A selects all).
405+ */
406+ export function buildKeyDispatchPlan (
407+ rawCombo : ParsedCombo ,
408+ platform : NodeJS . Platform = process . platform
409+ ) : [ cdp . CdpKeyEvent , cdp . CdpKeyEvent ] {
410+ const combo = normalizeComboForPlatform ( rawCombo , platform )
411+ const modifiers = cdpModifiers ( combo )
412+ const base = {
413+ modifiers,
414+ key : combo . key ,
415+ code : combo . code ,
416+ windowsVirtualKeyCode : combo . keyCode ,
417+ nativeVirtualKeyCode : combo . keyCode ,
418+ }
419+ const printable = combo . key . length === 1 && ! combo . ctrl && ! combo . meta
420+ const text = combo . key === 'Enter' ? '\r' : printable ? combo . key : undefined
421+ const commands = macEditingCommands ( combo , platform )
422+ const down : cdp . CdpKeyEvent = {
423+ ...base ,
424+ type : text !== undefined ? 'keyDown' : 'rawKeyDown' ,
425+ ...( text !== undefined ? { text } : { } ) ,
426+ ...( commands . length > 0 ? { commands } : { } ) ,
427+ }
428+ return [ down , { ...base , type : 'keyUp' } ]
429+ }
430+
431+ /** Presses a combo through the trusted pipeline. Throws on CDP failure. */
432+ async function dispatchKeyCombo ( contents : WebContents , combo : ParsedCombo ) : Promise < void > {
433+ const [ down , up ] = buildKeyDispatchPlan ( combo )
434+ await cdp . dispatchKeyEvent ( contents , down )
435+ await cdp . dispatchKeyEvent ( contents , up )
436+ }
437+
438+ /**
439+ * Post-action readback so the model sees the real effect (selection size,
440+ * value length) instead of assuming the key "worked".
441+ */
442+ async function activeElementState ( contents : WebContents ) : Promise < Record < string , unknown > > {
443+ const state = await execInPage ( contents , readActiveElementState , [ ] ) . catch ( ( ) => null )
444+ return typeof state === 'object' && state !== null ? ( state as Record < string , unknown > ) : { }
445+ }
446+
346447// ---------------------------------------------------------------------------
347448// Takeover
348449// ---------------------------------------------------------------------------
@@ -513,28 +614,62 @@ async function executeToolInner(
513614 }
514615
515616 case 'browser_type' : {
617+ const elementId = requireNum ( params , 'elementId' )
618+ const text = requireStr ( params , 'text' )
619+ const submit = params . submit === true
516620 const contents = session . requireTab ( ) . view . webContents
517- return unwrapPageResult (
518- await execInPage ( contents , typeIntoElement , [
519- requireNum ( params , 'elementId' ) ,
520- requireStr ( params , 'text' ) ,
521- params . submit === true ,
522- ] )
523- )
621+
622+ // Native path: focus + select current content, then insert through the
623+ // IME pipeline so the text REPLACES what's there — the only write path
624+ // code editors (CodeMirror/Monaco) honor. The DOM selection set by the
625+ // page function covers plain fields; the real select-all keystroke
626+ // right after covers editors that track selection in their own model
627+ // (their keymaps handle it synchronously, where DOM-selection sync is
628+ // async and can lose a race with the insert). Falls back to the
629+ // synthetic value-setter when CDP is unavailable.
630+ unwrapPageResult ( await execInPage ( contents , focusElementForTyping , [ elementId ] ) )
631+ try {
632+ await dispatchKeyCombo (
633+ contents ,
634+ parseKeyCombo ( process . platform === 'darwin' ? 'Cmd+A' : 'Control+A' )
635+ )
636+ await cdp . insertText ( contents , text )
637+ } catch {
638+ return unwrapPageResult (
639+ await execInPage ( contents , typeIntoElement , [ elementId , text , submit ] )
640+ )
641+ }
642+ if ( submit ) {
643+ await dispatchKeyCombo ( contents , parseKeyCombo ( 'Enter' ) ) . catch ( ( ) => { } )
644+ }
645+ const state = await activeElementState ( contents )
646+ return { typed : true , replacedExisting : true , submitted : submit , ...state }
524647 }
525648
526649 case 'browser_press_key' : {
527650 const combo = parseKeyCombo ( requireStr ( params , 'key' ) )
528651 const contents = session . requireTab ( ) . view . webContents
529- return await execInPage ( contents , pressKeyOnPage , [
530- combo . key ,
531- combo . code ,
532- combo . keyCode ,
533- combo . ctrl ,
534- combo . meta ,
535- combo . shift ,
536- combo . alt ,
537- ] )
652+ try {
653+ await dispatchKeyCombo ( contents , combo )
654+ } catch {
655+ // CDP unavailable (debugger detached): synthetic DOM fallback. It
656+ // cannot trigger default editing actions, so say so in the result.
657+ const fallback = await execInPage ( contents , pressKeyOnPage , [
658+ combo . key ,
659+ combo . code ,
660+ combo . keyCode ,
661+ combo . ctrl ,
662+ combo . meta ,
663+ combo . shift ,
664+ combo . alt ,
665+ ] )
666+ return {
667+ ...( typeof fallback === 'object' && fallback !== null ? fallback : { } ) ,
668+ note : 'Delivered as a synthetic page event; editing shortcuts may not take effect.' ,
669+ }
670+ }
671+ const state = await activeElementState ( contents )
672+ return { pressed : requireStr ( params , 'key' ) , ...state }
538673 }
539674
540675 case 'browser_scroll' : {
0 commit comments