From 0f962ef99b82207239e5dacb399be3cabce0ece3 Mon Sep 17 00:00:00 2001 From: amankumarsingh77 Date: Sat, 2 May 2026 16:05:09 +0530 Subject: feat(domains): migrate browser_click to defineBrowserTool — vertical slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 9: adds src/domains/click.ts with the new architecture, updates registry.ts to register clickTool, removes the legacy browser_click block from tools.ts, and wires BrowserClient alongside BrowserDaemon in index.ts so both systems coexist during migration. Also fixes AnyBrowserToolDefinition variance: uses never-arg renderCall in the erased type and a boundary cast in registerBrowserTool so concrete tool definitions (with specific Static args) are assignable to the registry array without widening. --- src/domains/click.ts | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 12 ++++++++ src/registry.ts | 3 +- src/tools.ts | 77 -------------------------------------------------- src/util/tool.ts | 23 +++++++++++---- 5 files changed, 110 insertions(+), 84 deletions(-) create mode 100644 src/domains/click.ts (limited to 'src') diff --git a/src/domains/click.ts b/src/domains/click.ts new file mode 100644 index 0000000..b3aa80a --- /dev/null +++ b/src/domains/click.ts @@ -0,0 +1,79 @@ +import { writeFile } from "node:fs/promises"; +import { Type, type Static } from "typebox"; +import { Text } from "@mariozechner/pi-tui"; +import type { BrowserClient } from "../client"; +import { Coords, MouseButton } from "../schemas/common"; +import { screenshotPath } from "../util/paths"; +import { type Result, err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr } from "../util/tool"; + +const ClickArgs = Type.Object({ + ...Coords.properties, + button: Type.Optional(MouseButton), + count: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 3, + default: 1, + description: "Number of clicks (1 = single, 2 = double). Default: 1", + }), + ), +}); +type ClickArgsT = Static; + +const dispatchClick = async ( + client: BrowserClient, + args: ClickArgsT, +): Promise> => { + const button = args.button ?? "left"; + const count = args.count ?? 1; + const pressed = await client.session().call("Input.dispatchMouseEvent", { + type: "mousePressed", x: args.x, y: args.y, button, clickCount: count, + }); + if (!pressed.success) return err({ kind: "cdp_error", message: pressed.error.message }); + const released = await client.session().call("Input.dispatchMouseEvent", { + type: "mouseReleased", x: args.x, y: args.y, button, clickCount: count, + }); + if (!released.success) return err({ kind: "cdp_error", message: released.error.message }); + return ok(undefined); +}; + +export const clickTool = defineBrowserTool({ + name: "browser_click", + label: "Browser Click", + description: + "Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Use browser_screenshot first to find coordinates.", + promptSnippet: "Click at pixel coordinates (x, y) on the page", + promptGuidelines: [ + "Use browser_click for all clicks. Coordinates are viewport CSS pixels (not device pixels).", + "Capture a browser_screenshot BEFORE clicking to find the right coordinates.", + "Capture another browser_screenshot AFTER clicking to verify the action worked.", + "Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.", + "If a click doesn't register, try setting BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions.", + "For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.", + ], + parameters: ClickArgs, + renderCall: (a) => new Text(`🖱️ Click at (${a.x}, ${a.y})`, 0, 0), + async handler(args, { client }) { + const clicked = await dispatchClick(client, args); + if (!clicked.success) return clicked; + if (process.env["BH_DEBUG_CLICKS"]) { + // Plain screenshot for now; the crosshair overlay returns in Task 14 + // (domains/screenshot.ts) once sharp-shim lands. + const path = screenshotPath(client.namespace, "png"); + const shot = await client.session().call("Page.captureScreenshot", { format: "png" }); + if (shot.success) { + const data = (shot.data as { data: string }).data; + await writeFile(path, Buffer.from(data, "base64")); + return ok({ + text: `Clicked at (${args.x}, ${args.y})\n[DEBUG] Screenshot: ${path}`, + details: { debugScreenshotPath: path, x: args.x, y: args.y }, + }); + } + } + return ok({ + text: `Clicked at (${args.x}, ${args.y})`, + details: { x: args.x, y: args.y }, + }); + }, +}); diff --git a/src/index.ts b/src/index.ts index 7a3464c..14ea8eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,8 +21,10 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { BrowserDaemon } from "./daemon"; +import { type BrowserClient, createBrowserClient } from "./client"; import { getBrowserSystemPrompt } from "./prompt"; import { registerRenderers } from "./renderers"; +import { registerAllTools } from "./registry"; import { registerSetupCommand } from "./setup"; import { type BrowserState, defaultState, persistState, restoreState } from "./state"; import { cleanupTempDirs, registerTools } from "./tools"; @@ -35,6 +37,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI) { // ── State ────────────────────────────────────────────────────────────────── let state: BrowserState = defaultState(namespace); let daemon: BrowserDaemon | null = null; + let client: BrowserClient | null = null; // ── Tool initialization flag ─────────────────────────────────────────────── let toolsRegistered = false; @@ -133,9 +136,13 @@ export default function browserHarnessExtension(pi: ExtensionAPI) { // Don't block — user can run /browser-setup later } + client = createBrowserClient({ namespace: state.namespace }); + try { await client.start(); } catch { /* surfaced via tool errors */ } + // Register tools (once) if (!toolsRegistered && daemon) { registerTools(pi, daemon); + if (client) registerAllTools(pi, client); toolsRegistered = true; } @@ -171,6 +178,11 @@ export default function browserHarnessExtension(pi: ExtensionAPI) { daemon = null; } + if (client) { + try { await client.stop(); } catch { /* best-effort */ } + client = null; + } + toolsRegistered = false; // Clean up temp files from truncated tool outputs diff --git a/src/registry.ts b/src/registry.ts index 014ba51..8d1fd27 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -1,9 +1,10 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import type { BrowserClient } from "./client"; import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool"; +import { clickTool } from "./domains/click"; const TOOLS: ReadonlyArray = [ - // Domain tools registered as they're migrated, one per domain file. + clickTool, ]; export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => { diff --git a/src/tools.ts b/src/tools.ts index 3ea0b54..134531c 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -318,83 +318,6 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { }, }); - // ═══════════════════════════════════════════════════════════════════════════ - // browser_click - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "browser_click", - label: "Browser Click", - description: - "Click at viewport CSS-pixel coordinates. Compositor-level click works through iframes, shadow DOM, and cross-origin content. Use browser_screenshot first to find coordinates.", - promptSnippet: "Click at pixel coordinates (x, y) on the page", - promptGuidelines: [ - "Use browser_click for all clicks. Coordinates are viewport CSS pixels (not device pixels).", - "Capture a browser_screenshot BEFORE clicking to find the right coordinates.", - "Capture another browser_screenshot AFTER clicking to verify the action worked.", - "Compositor-level clicks pass through iframes, shadow DOM, and cross-origin content — no selector needed.", - "If a click doesn't register, try setting BH_DEBUG_CLICKS=1 to get annotated screenshots showing exact click positions.", - "For React/Vue components that don't respond to clicks, try browser_dispatch_key to send DOM-level events.", - ], - parameters: Type.Object({ - x: Type.Number({ description: "X coordinate in CSS pixels from left edge of viewport" }), - y: Type.Number({ description: "Y coordinate in CSS pixels from top edge of viewport" }), - button: Type.Optional(Type.String({ description: 'Mouse button: "left", "right", or "middle". Default: "left"' })), - clicks: Type.Optional(Type.Number({ description: "Number of clicks (1 = single, 2 = double). Default: 1" })), - }), - async execute(_id, params) { - try { - await daemon.ensureAlive(); - await daemon.cdp("Input.dispatchMouseEvent", { - type: "mousePressed", - x: params.x, - y: params.y, - button: params.button || "left", - clickCount: params.clicks || 1, - }); - await daemon.cdp("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: params.x, - y: params.y, - button: params.button || "left", - clickCount: params.clicks || 1, - }); - - // Debug click overlay (when BH_DEBUG_CLICKS is set) - if (process.env.BH_DEBUG_CLICKS) { - try { - const debugPath = nextScreenshotPath(); - await daemon.captureScreenshotWithDebugOverlay( - debugPath, - params.x, - params.y, - ); - return { - content: [{ - type: "text" as const, - text: `Clicked at (${params.x}, ${params.y})\n[DEBUG] Overlay screenshot: ${debugPath}`, - }], - details: { debugScreenshotPath: debugPath }, - }; - } catch { - // Overlay failed — still report the click - } - } - - return { content: [{ type: "text" as const, text: `Clicked at (${params.x}, ${params.y})` }], details: undefined }; - } catch (err) { - return { - isError: true, - content: [{ type: "text" as const, text: `Click failed: ${err instanceof Error ? err.message : String(err)}` }], - details: undefined, - }; - } - }, - renderCall(args, theme) { - const { x, y } = args as { x: number; y: number }; - return new Text(`🖱️ Click at (${x}, ${y})`, 0, 0); - }, - }); - // ═══════════════════════════════════════════════════════════════════════════ // browser_type // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/util/tool.ts b/src/util/tool.ts index fc1c5e2..d269ba2 100644 --- a/src/util/tool.ts +++ b/src/util/tool.ts @@ -56,9 +56,14 @@ export type BrowserToolDefinition = { readonly ensureAlive?: boolean; }; -// Type-erased entry for arrays. AnyBrowserToolDefinition lets the registry -// hold a heterogeneous list without losing the per-tool Static binding. -export type AnyBrowserToolDefinition = BrowserToolDefinition; +// Type-erased entry for the registry's heterogeneous TOOLS array. +// renderCall accepts `never` so any concrete args type satisfies it via +// function-parameter contravariance (a handler that takes SpecificT is +// assignable to one that takes never, because never is a subtype of everything). +export type AnyBrowserToolDefinition = Omit, "renderCall" | "renderResult"> & { + readonly renderCall?: (args: never, theme: Theme) => Component; + readonly renderResult?: (result: AgentToolResult, expanded: boolean, theme: Theme) => Component; +}; export const defineBrowserTool = ( def: BrowserToolDefinition, @@ -94,11 +99,15 @@ const toToolResult = ( }; }; -export const registerBrowserTool = ( +export const registerBrowserTool = ( pi: ExtensionAPI, client: BrowserClient, - def: BrowserToolDefinition, + def: AnyBrowserToolDefinition, ): void => { + // AnyBrowserToolDefinition is type-erased; we recover the schema-typed surface + // by casting def to the generic form. The cast is safe because defineBrowserTool + // ensures the handler, parameters, and renderCall were all created with the same S. + type S = TSchema; const td: ToolDefinition = { name: def.name, label: def.label, @@ -110,7 +119,9 @@ export const registerBrowserTool = ( ? { renderCall: (args: Static, theme: Theme, _ctx: Parameters["renderCall"]>>[2]) => // Non-null assertion safe: this branch only runs when renderCall was provided. - def.renderCall!(args, theme), + // Cast from never->Component to unknown->Component: safe because at runtime + // args is always the concrete Static value the tool was defined with. + (def.renderCall as (args: unknown, theme: Theme) => Component)(args, theme), } : {}), ...(def.renderResult -- cgit v1.2.3