diff --git a/examples/press_key.py b/examples/press_key.py new file mode 100644 index 0000000..a63dd5d --- /dev/null +++ b/examples/press_key.py @@ -0,0 +1,48 @@ +import asyncio + +from narada import Narada + + +async def main() -> None: + async with Narada() as narada: + window = await narada.open_and_initialize_browser_window() + + await window.go_to_url( + url="https://w3c.github.io/uievents/tools/key-event-viewer.html", timeout=60 + ) + + # Dict items are accepted (same shape as JSON); PressKeyEventItem is optional. + await window.press_key( + events=[ + {"type": "keyDown", "code": "KeyA", "key": "a"}, + {"type": "keyUp", "code": "KeyA", "key": "a"}, + ], + ) + + await window.press_key( + events=[ + { + "type": "keyDown", + "code": "ShiftLeft", + "key": "Shift", + "modifiers": {"shift": True}, + }, + { + "type": "keyDown", + "code": "KeyA", + "key": "A", + "modifiers": {"shift": True}, + }, + { + "type": "keyUp", + "code": "KeyA", + "key": "A", + "modifiers": {"shift": True}, + }, + {"type": "keyUp", "code": "ShiftLeft", "key": "Shift"}, + ], + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/narada-core/src/narada_core/actions/models.py b/packages/narada-core/src/narada_core/actions/models.py index 25b5cbc..a838c31 100644 --- a/packages/narada-core/src/narada_core/actions/models.py +++ b/packages/narada-core/src/narada_core/actions/models.py @@ -419,8 +419,30 @@ class UserApprovalResponse(BaseModel): approved: bool +class RecordedKeyModifiers(TypedDict, total=False): + ctrl: bool + shift: bool + alt: bool + meta: bool + + +class PressKeyEventItem(BaseModel): + type: Literal["keyDown", "keyUp", "press"] = "keyDown" # noqa: A003 + code: str + key: str | None = None + modifiers: RecordedKeyModifiers | None = None + + +class PressKeyRequest(BaseModel): + """Wire payload: key events for the extension to replay via ``debuggerPress``.""" + + name: Literal["press_key"] = "press_key" + events: list[PressKeyEventItem] + + type ExtensionActionRequest = ( AgenticSelectorRequest + | PressKeyRequest | AgenticMouseActionRequest | CloseWindowRequest | GoToUrlRequest diff --git a/packages/narada-pyodide/src/narada/__init__.py b/packages/narada-pyodide/src/narada/__init__.py index 1c5c77b..44ed030 100644 --- a/packages/narada-pyodide/src/narada/__init__.py +++ b/packages/narada-pyodide/src/narada/__init__.py @@ -1,4 +1,4 @@ -from narada_core.actions.models import CriticResult +from narada_core.actions.models import CriticResult, PressKeyEventItem from narada_core.errors import ( NaradaError, NaradaTimeoutError, @@ -27,6 +27,7 @@ "CloudBrowserWindow", "CriticConfig", "CriticResult", + "PressKeyEventItem", "download_file", "File", "LocalBrowserWindow", diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index 4c6809c..6b9a4fc 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -11,7 +11,9 @@ TYPE_CHECKING, Any, Literal, + Mapping, Optional, + Sequence, TypeVar, cast, overload, @@ -43,6 +45,8 @@ GetUrlRequest, GetUrlResponse, GoToUrlRequest, + PressKeyEventItem, + PressKeyRequest, PrintMessageRequest, PromptForUserInputRequest, PromptForUserInputResponse, @@ -695,6 +699,7 @@ async def agentic_selector( action: AgenticSelectorAction, selectors: AgenticSelectors, fallback_operator_query: str, + nth_match: Optional[str] = None, # Larger default timeout because Operator can take a bit to run. timeout: int | None = 300, ) -> AgenticSelectorResponse: @@ -715,6 +720,7 @@ async def agentic_selector( action=action, selectors=selectors, fallback_operator_query=fallback_operator_query, + nth_match=nth_match, ), response_model, timeout=timeout, @@ -725,6 +731,41 @@ async def agentic_selector( return result + async def press_key( + self, + *, + events: Sequence[PressKeyEventItem | Mapping[str, Any]], + timeout: int | None = 60, + ) -> None: + """Send keyboard events on the active tab (Chrome debugger). + + Each item uses ``type`` of ``\"keyDown\"``, ``\"keyUp\"``, or ``\"press\"``. + ``code`` is required per item; ``key`` and ``modifiers`` are optional. + + Items may be :class:`PressKeyEventItem` instances or plain mappings (e.g. dicts from + ``json.loads``) with the same keys:: + + await window.press_key(events=[ + {"type": "keyDown", "code": "KeyA", "key": "a"}, + {"type": "keyUp", "code": "KeyA", "key": "a"}, + ]) + """ + + if not events: + raise ValueError("press_key requires a non-empty events= sequence") + + normalized: list[PressKeyEventItem] = [ + event + if isinstance(event, PressKeyEventItem) + else PressKeyEventItem.model_validate(event) + for event in events + ] + + await self._run_extension_action( + PressKeyRequest(events=normalized), + timeout=timeout, + ) + async def agentic_mouse_action( self, *, diff --git a/packages/narada/src/narada/__init__.py b/packages/narada/src/narada/__init__.py index 719f28d..8f7a0fb 100644 --- a/packages/narada/src/narada/__init__.py +++ b/packages/narada/src/narada/__init__.py @@ -1,4 +1,4 @@ -from narada_core.actions.models import CriticResult +from narada_core.actions.models import CriticResult, PressKeyEventItem from narada_core.errors import ( NaradaError, NaradaExtensionMissingError, @@ -30,6 +30,7 @@ "CloudBrowserWindow", "CriticConfig", "CriticResult", + "PressKeyEventItem", "download_file", "File", "LocalBrowserWindow", diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index c577951..270fe5d 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -13,6 +13,7 @@ Any, Literal, Mapping, + Sequence, TypedDict, TypeGuard, TypeVar, @@ -44,6 +45,8 @@ GetUrlRequest, GetUrlResponse, GoToUrlRequest, + PressKeyEventItem, + PressKeyRequest, PrintMessageRequest, PromptForUserInputRequest, PromptForUserInputResponse, @@ -696,6 +699,41 @@ async def agentic_selector( return result + async def press_key( + self, + *, + events: Sequence[PressKeyEventItem | Mapping[str, Any]], + timeout: int | None = 60, + ) -> None: + """Send keyboard events on the active tab (Chrome debugger). + + Each item uses ``type`` of ``\"keyDown\"``, ``\"keyUp\"``, or ``\"press\"`` (Playwright/CDP + semantics). ``code`` is required per item; ``key`` and ``modifiers`` are optional. + + Items may be :class:`PressKeyEventItem` instances or plain mappings (e.g. dicts parsed + from JSON) with the same keys:: + + await window.press_key(events=[ + {"type": "keyDown", "code": "KeyA", "key": "a"}, + {"type": "keyUp", "code": "KeyA", "key": "a"}, + ]) + """ + + if not events: + raise ValueError("press_key requires a non-empty events= sequence") + + normalized: list[PressKeyEventItem] = [ + event + if isinstance(event, PressKeyEventItem) + else PressKeyEventItem.model_validate(event) + for event in events + ] + + await self._run_extension_action( + PressKeyRequest(events=normalized), + timeout=timeout, + ) + async def agentic_mouse_action( self, *,