aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 16:00:58 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 16:00:58 +0530
commit6a4bfa31a925af67c026cd68700fe7779fd7c4cb (patch)
tree640a95ed6b17787bd4022a21f73bd4c0a83d8f40
parentd3d86f9de1cd3465cd1ec1e9a34c36b2f4878694 (diff)
feat: add defineBrowserTool helper, truncation, schemas/common, empty registry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--src/registry.ts11
-rw-r--r--src/schemas/common.ts11
-rw-r--r--src/util/tool.ts158
-rw-r--r--src/util/truncate.ts35
4 files changed, 215 insertions, 0 deletions
diff --git a/src/registry.ts b/src/registry.ts
new file mode 100644
index 0000000..014ba51
--- /dev/null
+++ b/src/registry.ts
@@ -0,0 +1,11 @@
+import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { BrowserClient } from "./client";
+import { type AnyBrowserToolDefinition, registerBrowserTool } from "./util/tool";
+
+const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
+ // Domain tools registered as they're migrated, one per domain file.
+];
+
+export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
+ for (const t of TOOLS) registerBrowserTool(pi, client, t);
+};
diff --git a/src/schemas/common.ts b/src/schemas/common.ts
new file mode 100644
index 0000000..00323c2
--- /dev/null
+++ b/src/schemas/common.ts
@@ -0,0 +1,11 @@
+import { Type } from "typebox";
+
+export const Coords = 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" }),
+});
+
+export const MouseButton = Type.Union(
+ [Type.Literal("left"), Type.Literal("right"), Type.Literal("middle")],
+ { description: 'Mouse button: "left", "right", or "middle"' },
+);
diff --git a/src/util/tool.ts b/src/util/tool.ts
new file mode 100644
index 0000000..fc1c5e2
--- /dev/null
+++ b/src/util/tool.ts
@@ -0,0 +1,158 @@
+import type { TSchema, Static } from "typebox";
+import type {
+ AgentToolResult,
+ AgentToolUpdateCallback,
+ ExtensionAPI,
+ ExtensionContext,
+ Theme,
+ ToolDefinition,
+ ToolRenderResultOptions,
+} from "@mariozechner/pi-coding-agent";
+import type { Component } from "@mariozechner/pi-tui";
+import type { BrowserClient } from "../client";
+import type { Result } from "./result";
+
+export type ToolOk = {
+ readonly text: string;
+ readonly details?: Readonly<Record<string, unknown>>;
+};
+
+export type ToolErrKind =
+ | "not_connected"
+ | "cdp_error"
+ | "timeout"
+ | "invalid_state"
+ | "io_error"
+ | "internal";
+
+export type ToolErr = {
+ readonly kind: ToolErrKind;
+ readonly message: string;
+ readonly details?: Readonly<Record<string, unknown>>;
+};
+
+export type HandlerContext = {
+ readonly client: BrowserClient;
+ readonly signal: AbortSignal | undefined;
+ readonly onUpdate: (update: ToolOk) => void;
+ readonly extensionCtx: ExtensionContext;
+};
+
+export type ToolHandler<S extends TSchema> = (
+ args: Static<S>,
+ ctx: HandlerContext,
+) => Promise<Result<ToolOk, ToolErr>>;
+
+export type BrowserToolDefinition<S extends TSchema> = {
+ readonly name: string;
+ readonly label: string;
+ readonly description: string;
+ readonly promptSnippet: string;
+ readonly promptGuidelines: ReadonlyArray<string>;
+ readonly parameters: S;
+ readonly handler: ToolHandler<S>;
+ readonly renderCall?: (args: Static<S>, theme: Theme) => Component;
+ readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component;
+ readonly ensureAlive?: boolean;
+};
+
+// Type-erased entry for arrays. AnyBrowserToolDefinition lets the registry
+// hold a heterogeneous list without losing the per-tool Static<S> binding.
+export type AnyBrowserToolDefinition = BrowserToolDefinition<TSchema>;
+
+export const defineBrowserTool = <S extends TSchema>(
+ def: BrowserToolDefinition<S>,
+): BrowserToolDefinition<S> => def;
+
+type OkDetails = { readonly ok: true } & Readonly<Record<string, unknown>>;
+type ErrDetails = { readonly ok: false; readonly kind: ToolErrKind; readonly message: string } & Readonly<Record<string, unknown>>;
+
+const toToolResult = (
+ r: Result<ToolOk, ToolErr>,
+ toolName: string,
+): AgentToolResult<OkDetails | ErrDetails> => {
+ if (r.success) {
+ const details: OkDetails = { ok: true, ...(r.data.details ?? {}) };
+ return {
+ content: [{ type: "text", text: r.data.text }],
+ details,
+ };
+ }
+ const details: ErrDetails = {
+ ok: false,
+ kind: r.error.kind,
+ message: r.error.message,
+ ...(r.error.details ?? {}),
+ };
+ return {
+ // isError is an extension the pi-coding-agent runtime picks up from the result object;
+ // AgentToolResult<T> itself doesn't declare it but the runtime reads it via duck-typing.
+ // We cast through unknown to avoid widening the return type.
+ ...(({ isError: true }) as unknown as object),
+ content: [{ type: "text", text: `${toolName} failed (${r.error.kind}): ${r.error.message}` }],
+ details,
+ };
+};
+
+export const registerBrowserTool = <S extends TSchema>(
+ pi: ExtensionAPI,
+ client: BrowserClient,
+ def: BrowserToolDefinition<S>,
+): void => {
+ const td: ToolDefinition<S> = {
+ name: def.name,
+ label: def.label,
+ description: def.description,
+ promptSnippet: def.promptSnippet,
+ promptGuidelines: [...def.promptGuidelines],
+ parameters: def.parameters,
+ ...(def.renderCall
+ ? {
+ renderCall: (args: Static<S>, theme: Theme, _ctx: Parameters<NonNullable<ToolDefinition<S>["renderCall"]>>[2]) =>
+ // Non-null assertion safe: this branch only runs when renderCall was provided.
+ def.renderCall!(args, theme),
+ }
+ : {}),
+ ...(def.renderResult
+ ? {
+ renderResult: (
+ result: AgentToolResult<unknown>,
+ options: ToolRenderResultOptions,
+ theme: Theme,
+ _ctx: Parameters<NonNullable<ToolDefinition<S>["renderResult"]>>[3],
+ ) =>
+ // Non-null assertion safe: this branch only runs when renderResult was provided.
+ def.renderResult!(result, options.expanded ?? false, theme),
+ }
+ : {}),
+ async execute(_toolCallId, args, signal, onUpdate, extensionCtx) {
+ if (def.ensureAlive !== false) {
+ const alive = await client.ensureAlive();
+ if (!alive.success) {
+ return toToolResult(
+ { success: false, error: { kind: "not_connected", message: alive.error.message } },
+ def.name,
+ );
+ }
+ }
+ const result = await def.handler(args, {
+ client,
+ signal,
+ onUpdate: (u) => {
+ if (onUpdate) {
+ // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails.
+ const update: AgentToolResult<OkDetails> = {
+ content: [{ type: "text", text: u.text }],
+ details: { ok: true, ...(u.details ?? {}) },
+ };
+ // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails).
+ (onUpdate as AgentToolUpdateCallback<OkDetails>)(update);
+ }
+ },
+ extensionCtx,
+ });
+ return toToolResult(result, def.name);
+ },
+ };
+ pi.registerTool(td);
+};
diff --git a/src/util/truncate.ts b/src/util/truncate.ts
new file mode 100644
index 0000000..2bf0600
--- /dev/null
+++ b/src/util/truncate.ts
@@ -0,0 +1,35 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ DEFAULT_MAX_BYTES,
+ DEFAULT_MAX_LINES,
+ formatSize,
+ truncateHead,
+ withFileMutationQueue,
+} from "@mariozechner/pi-coding-agent";
+
+const tempDirs: string[] = [];
+
+export const cleanupTempDirs = async (): Promise<void> => {
+ const dirs = tempDirs.splice(0);
+ await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true }).catch(() => {})));
+};
+
+export type TruncatedOutput = {
+ readonly text: string;
+ readonly fullOutputPath?: string;
+ readonly wasTruncated: boolean;
+};
+
+export const applyTruncation = async (output: string, prefix: string): Promise<TruncatedOutput> => {
+ const t = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
+ if (!t.truncated) return { text: t.content, wasTruncated: false };
+ const dir = await mkdtemp(join(tmpdir(), `pi-bh-${prefix}-`));
+ tempDirs.push(dir);
+ const file = join(dir, "output.txt");
+ await withFileMutationQueue(file, async () => { await writeFile(file, output, "utf8"); });
+ const omitted = t.totalBytes - t.outputBytes;
+ const text = `${t.content}\n\n[Output truncated: ${t.outputLines} of ${t.totalLines} lines (${formatSize(t.outputBytes)} of ${formatSize(t.totalBytes)}). ${formatSize(omitted)} omitted. Full output: ${file}]`;
+ return { text, fullOutputPath: file, wasTruncated: true };
+};