diff --git a/README.md b/README.md index 75f7ad6e..8f11143e 100644 --- a/README.md +++ b/README.md @@ -529,37 +529,98 @@ export default function RootLayout({ children }) { ## Extending React Grab -React Grab provides an public customization API. Check out the [type definitions](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) to see all available options for extending React Grab. +React Grab uses a plugin system to extend functionality. Check out the [type definitions](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) to see all available options. + +#### Basic Usage ```typescript import { init } from "react-grab/core"; -const api = init({ - theme: { - enabled: true, // disable all UI by setting to false - hue: 180, // shift colors by 180 degrees (pink → cyan/turquoise) - crosshair: { - enabled: false, // disable crosshair +const api = init(); + +api.activate(); +api.copyElement(document.querySelector(".my-element")); +console.log(api.getState()); +``` + +#### Lifecycle Hooks Plugin + +Track element selections with analytics: + +```typescript +api.registerPlugin({ + name: "analytics", + hooks: { + onElementSelect: (element) => { + analytics.track("element_selected", { tagName: element.tagName }); + }, + onDragEnd: (elements, bounds) => { + analytics.track("drag_end", { count: elements.length, bounds }); }, - elementLabel: { - enabled: false, // disable element label + onCopySuccess: (elements, content) => { + analytics.track("copy", { count: elements.length }); }, }, +}); +``` - onElementSelect: (element) => { - console.log("Selected:", element); - }, - onCopySuccess: (elements, content) => { - console.log("Copied to clipboard:", content); - }, - onStateChange: (state) => { - console.log("Active:", state.isActive); +#### Context Menu Plugin + +Add custom actions to the right-click menu: + +```typescript +api.registerPlugin({ + name: "custom-actions", + contextMenuActions: [ + { + label: "Log to Console", + handler: ({ elements }) => console.dir(elements[0]), + }, + ], +}); +``` + +#### Theme Plugin + +Customize the UI appearance: + +```typescript +api.registerPlugin({ + name: "theme", + theme: { + hue: 180, // shift colors (pink → cyan) + crosshair: { enabled: false }, + elementLabel: { enabled: false }, }, }); +``` -api.activate(); -api.copyElement(document.querySelector(".my-element")); -console.log(api.getState()); +#### Agent Plugin + +Create a custom agent that processes selected elements: + +```typescript +api.registerPlugin({ + name: "my-custom-agent", + agent: { + provider: { + async *send({ prompt, elements, content }) { + yield "Analyzing element..."; + + const response = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, content }), + }); + + yield "Processing response..."; + + const result = await response.json(); + yield `Done: ${result.message}`; + }, + }, + }, +}); ``` ## Resources & Contributing Back diff --git a/packages/grab/README.md b/packages/grab/README.md index 7230c868..1242e0f4 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -529,37 +529,98 @@ export default function RootLayout({ children }) { ## Extending React Grab -React Grab provides an public customization API. Check out the [type definitions](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) to see all available options for extending React Grab. +React Grab uses a plugin system to extend functionality. Check out the [type definitions](https://github.com/aidenybai/react-grab/blob/main/packages/react-grab/src/types.ts) to see all available options. + +#### Basic Usage ```typescript import { init } from "grab/core"; -const api = init({ - theme: { - enabled: true, // disable all UI by setting to false - hue: 180, // shift colors by 180 degrees (pink → cyan/turquoise) - crosshair: { - enabled: false, // disable crosshair +const api = init(); + +api.activate(); +api.copyElement(document.querySelector(".my-element")); +console.log(api.getState()); +``` + +#### Lifecycle Hooks Plugin + +Track element selections with analytics: + +```typescript +api.registerPlugin({ + name: "analytics", + hooks: { + onElementSelect: (element) => { + analytics.track("element_selected", { tagName: element.tagName }); + }, + onDragEnd: (elements, bounds) => { + analytics.track("drag_end", { count: elements.length, bounds }); }, - elementLabel: { - enabled: false, // disable element label + onCopySuccess: (elements, content) => { + analytics.track("copy", { count: elements.length }); }, }, +}); +``` - onElementSelect: (element) => { - console.log("Selected:", element); - }, - onCopySuccess: (elements, content) => { - console.log("Copied to clipboard:", content); - }, - onStateChange: (state) => { - console.log("Active:", state.isActive); +#### Context Menu Plugin + +Add custom actions to the right-click menu: + +```typescript +api.registerPlugin({ + name: "custom-actions", + contextMenuActions: [ + { + label: "Log to Console", + handler: ({ elements }) => console.dir(elements[0]), + }, + ], +}); +``` + +#### Theme Plugin + +Customize the UI appearance: + +```typescript +api.registerPlugin({ + name: "theme", + theme: { + hue: 180, // shift colors (pink → cyan) + crosshair: { enabled: false }, + elementLabel: { enabled: false }, }, }); +``` -api.activate(); -api.copyElement(document.querySelector(".my-element")); -console.log(api.getState()); +#### Agent Plugin + +Create a custom agent that processes selected elements: + +```typescript +api.registerPlugin({ + name: "my-custom-agent", + agent: { + provider: { + async *send({ prompt, elements, content }) { + yield "Analyzing element..."; + + const response = await fetch("/api/ai", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, content }), + }); + + yield "Processing response..."; + + const result = await response.json(); + yield `Done: ${result.message}`; + }, + }, + }, +}); ``` ## Resources & Contributing Back diff --git a/packages/provider-ami/src/client.ts b/packages/provider-ami/src/client.ts index 79d1a4fd..a90add9b 100644 --- a/packages/provider-ami/src/client.ts +++ b/packages/provider-ami/src/client.ts @@ -250,7 +250,7 @@ const runAgent = async ( }; const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; interface SessionData { messages: AmiUIMessage[]; @@ -550,7 +550,10 @@ export const attachAgent = async () => { const provider = createAmiAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "ami-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-amp/src/client.ts b/packages/provider-amp/src/client.ts index fa201f57..651f2986 100644 --- a/packages/provider-amp/src/client.ts +++ b/packages/provider-amp/src/client.ts @@ -29,7 +29,7 @@ interface AmpAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createAmpAgentProvider = ( providerOptions: AmpAgentProviderOptions = {}, @@ -115,7 +115,10 @@ export const attachAgent = async () => { const provider = createAmpAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "amp-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-claude-code/src/client.ts b/packages/provider-claude-code/src/client.ts index 2cc55eb0..1afb0c21 100644 --- a/packages/provider-claude-code/src/client.ts +++ b/packages/provider-claude-code/src/client.ts @@ -39,7 +39,7 @@ interface ClaudeAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createClaudeAgentProvider = ( providerOptions: ClaudeAgentProviderOptions = {}, @@ -120,7 +120,10 @@ export const attachAgent = async () => { const provider = createClaudeAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "claude-code-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-codex/src/client.ts b/packages/provider-codex/src/client.ts index cdc9a54b..5c65f4e1 100644 --- a/packages/provider-codex/src/client.ts +++ b/packages/provider-codex/src/client.ts @@ -30,7 +30,7 @@ interface CodexAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createCodexAgentProvider = ( options: CodexAgentProviderOptions = {}, @@ -112,7 +112,10 @@ export const attachAgent = async () => { const provider = createCodexAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "codex-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-cursor/src/client.ts b/packages/provider-cursor/src/client.ts index 872c0d5d..15deedfa 100644 --- a/packages/provider-cursor/src/client.ts +++ b/packages/provider-cursor/src/client.ts @@ -31,7 +31,7 @@ interface CursorAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createCursorAgentProvider = ( providerOptions: CursorAgentProviderOptions = {}, @@ -119,7 +119,10 @@ export const attachAgent = async () => { const provider = createCursorAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "cursor-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-droid/src/client.ts b/packages/provider-droid/src/client.ts index f5b51bbe..7b4c77d2 100644 --- a/packages/provider-droid/src/client.ts +++ b/packages/provider-droid/src/client.ts @@ -36,7 +36,7 @@ interface DroidAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createDroidAgentProvider = ( providerOptions: DroidAgentProviderOptions = {}, @@ -119,7 +119,10 @@ export const attachAgent = async () => { const provider = createDroidAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "droid-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-gemini/src/client.ts b/packages/provider-gemini/src/client.ts index 98c02a93..8b55cc1e 100644 --- a/packages/provider-gemini/src/client.ts +++ b/packages/provider-gemini/src/client.ts @@ -30,7 +30,7 @@ interface GeminiAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createGeminiAgentProvider = ( providerOptions: GeminiAgentProviderOptions = {}, @@ -118,7 +118,10 @@ export const attachAgent = async () => { const provider = createGeminiAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "gemini-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-opencode/src/client.ts b/packages/provider-opencode/src/client.ts index 5e10bdef..455a7295 100644 --- a/packages/provider-opencode/src/client.ts +++ b/packages/provider-opencode/src/client.ts @@ -32,7 +32,7 @@ interface OpenCodeAgentProviderOptions { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; export const createOpenCodeAgentProvider = ( options: OpenCodeAgentProviderOptions = {}, @@ -114,7 +114,10 @@ export const attachAgent = async () => { const provider = createOpenCodeAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ agent: { provider, storage: sessionStorage } }); + api.registerPlugin({ + name: "opencode-agent", + agent: { provider, storage: sessionStorage }, + }); }; const existingApi = window.__REACT_GRAB__; diff --git a/packages/provider-visual-edit/src/client/index.ts b/packages/provider-visual-edit/src/client/index.ts index bd5efa0f..4258fba3 100644 --- a/packages/provider-visual-edit/src/client/index.ts +++ b/packages/provider-visual-edit/src/client/index.ts @@ -629,7 +629,7 @@ declare global { } const isReactGrabApi = (value: unknown): value is ReactGrabAPI => - typeof value === "object" && value !== null && "setOptions" in value; + typeof value === "object" && value !== null && "registerPlugin" in value; const checkHealth = async (): Promise => { const controller = new AbortController(); @@ -662,7 +662,8 @@ export const attachAgent = async () => { createVisualEditAgentProvider(); const attach = (api: ReactGrabAPI) => { - api.setOptions({ + api.registerPlugin({ + name: "visual-edit-agent", agent: { provider, getOptions, diff --git a/packages/react-grab/e2e/api-methods.spec.ts b/packages/react-grab/e2e/api-methods.spec.ts index cc446388..200f4e8e 100644 --- a/packages/react-grab/e2e/api-methods.spec.ts +++ b/packages/react-grab/e2e/api-methods.spec.ts @@ -270,8 +270,8 @@ test.describe("API Methods", () => { }); }); - test.describe("setOptions()", () => { - test("should update callback options", async ({ reactGrab }) => { + test.describe("registerPlugin()", () => { + test("should register plugin with hooks", async ({ reactGrab }) => { let callbackCalled = false; await reactGrab.page.evaluate(() => { @@ -281,15 +281,18 @@ test.describe("API Methods", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onActivate: () => { - ( - window as { __TEST_CALLBACK_CALLED__?: boolean } - ).__TEST_CALLBACK_CALLED__ = true; + api?.registerPlugin({ + name: "test-plugin", + hooks: { + onActivate: () => { + ( + window as { __TEST_CALLBACK_CALLED__?: boolean } + ).__TEST_CALLBACK_CALLED__ = true; + }, }, }); }); @@ -306,26 +309,29 @@ test.describe("API Methods", () => { expect(callbackCalled).toBe(true); }); - test("should allow updating multiple callbacks", async ({ reactGrab }) => { + test("should allow registering plugin with multiple hooks", async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { (window as { __CALLBACKS__?: string[] }).__CALLBACKS__ = []; const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onActivate: () => { - (window as { __CALLBACKS__?: string[] }).__CALLBACKS__?.push( - "activate", - ); - }, - onDeactivate: () => { - (window as { __CALLBACKS__?: string[] }).__CALLBACKS__?.push( - "deactivate", - ); + api?.registerPlugin({ + name: "test-plugin", + hooks: { + onActivate: () => { + (window as { __CALLBACKS__?: string[] }).__CALLBACKS__?.push( + "activate", + ); + }, + onDeactivate: () => { + (window as { __CALLBACKS__?: string[] }).__CALLBACKS__?.push( + "deactivate", + ); + }, }, }); }); diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 9f24dd1a..8c41ed97 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -979,10 +979,14 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { await page.evaluate((opts) => { const api = ( window as { - __REACT_GRAB__?: { setOptions: (o: Record) => void }; + __REACT_GRAB__?: { + unregisterPlugin: (name: string) => void; + registerPlugin: (plugin: { name: string; agent: Record }) => void; + }; } ).__REACT_GRAB__; - api?.setOptions({ agent: opts }); + api?.unregisterPlugin("test-agent"); + api?.registerPlugin({ name: "test-agent", agent: opts }); }, options); await page.waitForTimeout(100); }; @@ -993,10 +997,47 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { window as { __REACT_GRAB__?: { setOptions: (o: Record) => void; + unregisterPlugin: (name: string) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions(opts); + + const pluginKeys = ["theme", "agent", "contextMenuActions"]; + const hookKeys = [ + "onActivate", "onDeactivate", "onElementHover", "onElementSelect", + "onDragStart", "onDragEnd", "onBeforeCopy", "onAfterCopy", + "onCopySuccess", "onCopyError", "onStateChange", "onPromptModeChange", + "onSelectionBox", "onDragBox", "onCrosshair", "onGrabbedBox", + "onContextMenu", "onOpenFile", "onElementLabel", + ]; + + const pluginOpts: Record = {}; + const hooks: Record = {}; + const regularOpts: Record = {}; + + for (const [key, value] of Object.entries(opts)) { + if (pluginKeys.includes(key)) { + pluginOpts[key] = value; + } else if (hookKeys.includes(key)) { + hooks[key] = value; + } else { + regularOpts[key] = value; + } + } + + if (Object.keys(regularOpts).length > 0) { + api?.setOptions(regularOpts); + } + + if (Object.keys(pluginOpts).length > 0 || Object.keys(hooks).length > 0) { + api?.unregisterPlugin("test-options"); + api?.registerPlugin({ + name: "test-options", + ...pluginOpts, + ...(Object.keys(hooks).length > 0 ? { hooks } : {}), + }); + } }, options); await page.waitForTimeout(100); }; @@ -1051,10 +1092,14 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const api = ( window as { - __REACT_GRAB__?: { setOptions: (o: Record) => void }; + __REACT_GRAB__?: { + unregisterPlugin: (name: string) => void; + registerPlugin: (plugin: { name: string; agent: Record }) => void; + }; } ).__REACT_GRAB__; - api?.setOptions({ agent: { provider: mockProvider } }); + api?.unregisterPlugin("mock-agent"); + api?.registerPlugin({ name: "mock-agent", agent: { provider: mockProvider } }); }, options); await page.waitForTimeout(100); }; @@ -1390,29 +1435,34 @@ const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + unregisterPlugin: (name: string) => void; + registerPlugin: (plugin: { name: string; hooks: Record }) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onActivate: trackCallback("onActivate"), - onDeactivate: trackCallback("onDeactivate"), - onElementHover: trackCallback("onElementHover"), - onElementSelect: trackCallback("onElementSelect"), - onDragStart: trackCallback("onDragStart"), - onDragEnd: trackCallback("onDragEnd"), - onBeforeCopy: trackCallback("onBeforeCopy"), - onAfterCopy: trackCallback("onAfterCopy"), - onCopySuccess: trackCallback("onCopySuccess"), - onCopyError: trackCallback("onCopyError"), - onStateChange: trackCallback("onStateChange"), - onPromptModeChange: trackCallback("onPromptModeChange"), - onSelectionBox: trackCallback("onSelectionBox"), - onDragBox: trackCallback("onDragBox"), - onCrosshair: trackCallback("onCrosshair"), - onGrabbedBox: trackCallback("onGrabbedBox"), - onContextMenu: trackCallback("onContextMenu"), - onOpenFile: trackCallback("onOpenFile"), + api?.unregisterPlugin("callback-tracking"); + api?.registerPlugin({ + name: "callback-tracking", + hooks: { + onActivate: trackCallback("onActivate"), + onDeactivate: trackCallback("onDeactivate"), + onElementHover: trackCallback("onElementHover"), + onElementSelect: trackCallback("onElementSelect"), + onDragStart: trackCallback("onDragStart"), + onDragEnd: trackCallback("onDragEnd"), + onBeforeCopy: trackCallback("onBeforeCopy"), + onAfterCopy: trackCallback("onAfterCopy"), + onCopySuccess: trackCallback("onCopySuccess"), + onCopyError: trackCallback("onCopyError"), + onStateChange: trackCallback("onStateChange"), + onPromptModeChange: trackCallback("onPromptModeChange"), + onSelectionBox: trackCallback("onSelectionBox"), + onDragBox: trackCallback("onDragBox"), + onCrosshair: trackCallback("onCrosshair"), + onGrabbedBox: trackCallback("onGrabbedBox"), + onContextMenu: trackCallback("onContextMenu"), + onOpenFile: trackCallback("onOpenFile"), + }, }); }); }; diff --git a/packages/react-grab/e2e/open-file.spec.ts b/packages/react-grab/e2e/open-file.spec.ts index efeb5c43..a9a10b86 100644 --- a/packages/react-grab/e2e/open-file.spec.ts +++ b/packages/react-grab/e2e/open-file.spec.ts @@ -13,15 +13,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => { - ( - window as { __OPEN_FILE_CALLED__?: boolean } - ).__OPEN_FILE_CALLED__ = true; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => { + ( + window as { __OPEN_FILE_CALLED__?: boolean } + ).__OPEN_FILE_CALLED__ = true; + }, }, }); }); @@ -70,15 +73,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => { - ( - window as { __OPEN_FILE_CALLED__?: boolean } - ).__OPEN_FILE_CALLED__ = true; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => { + ( + window as { __OPEN_FILE_CALLED__?: boolean } + ).__OPEN_FILE_CALLED__ = true; + }, }, }); }); @@ -107,12 +113,15 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => {}, + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => {}, + }, }); }); @@ -138,15 +147,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => { - ( - window as { __OPEN_FILE_CALLED__?: boolean } - ).__OPEN_FILE_CALLED__ = true; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => { + ( + window as { __OPEN_FILE_CALLED__?: boolean } + ).__OPEN_FILE_CALLED__ = true; + }, }, }); }); @@ -195,14 +207,17 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: (info: unknown) => { - (window as { __OPEN_FILE_INFO__?: unknown }).__OPEN_FILE_INFO__ = - info; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: (info: unknown) => { + (window as { __OPEN_FILE_INFO__?: unknown }).__OPEN_FILE_INFO__ = + info; + }, }, }); }); @@ -235,15 +250,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: (info: Record) => { - ( - window as { __OPEN_FILE_INFO__?: Record | null } - ).__OPEN_FILE_INFO__ = info; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: (info: Record) => { + ( + window as { __OPEN_FILE_INFO__?: Record | null } + ).__OPEN_FILE_INFO__ = info; + }, }, }); }); @@ -279,15 +297,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => { - ( - window as { __OPEN_FILE_CALLED__?: boolean } - ).__OPEN_FILE_CALLED__ = true; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => { + ( + window as { __OPEN_FILE_CALLED__?: boolean } + ).__OPEN_FILE_CALLED__ = true; + }, }, }); }); @@ -339,15 +360,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => { - (window as { __OPEN_FILE_COUNT__?: number }).__OPEN_FILE_COUNT__ = - ((window as { __OPEN_FILE_COUNT__?: number }) - .__OPEN_FILE_COUNT__ ?? 0) + 1; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => { + (window as { __OPEN_FILE_COUNT__?: number }).__OPEN_FILE_COUNT__ = + ((window as { __OPEN_FILE_COUNT__?: number }) + .__OPEN_FILE_COUNT__ ?? 0) + 1; + }, }, }); }); @@ -388,15 +412,18 @@ test.describe("Open File", () => { const api = ( window as { __REACT_GRAB__?: { - setOptions: (o: Record) => void; + registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; - api?.setOptions({ - onOpenFile: () => { - ( - window as { __OPEN_FILE_CALLED__?: boolean } - ).__OPEN_FILE_CALLED__ = true; + api?.registerPlugin({ + name: "test-open-file", + hooks: { + onOpenFile: () => { + ( + window as { __OPEN_FILE_CALLED__?: boolean } + ).__OPEN_FILE_CALLED__ = true; + }, }, }); }); diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 42a69e12..a378854d 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,16 +1,28 @@ -import type { Options } from "../types.js"; import { copyContent } from "../utils/copy-content.js"; import { generateSnippet } from "../utils/generate-snippet.js"; +interface CopyOptions { + maxContextLines?: number; + getContent?: (elements: Element[]) => Promise | string; +} + +interface CopyHooks { + onBeforeCopy: (elements: Element[]) => Promise; + onAfterCopy: (elements: Element[], success: boolean) => void; + onCopySuccess: (elements: Element[], content: string) => void; + onCopyError: (error: Error) => void; +} + export const tryCopyWithFallback = async ( - options: Options, + options: CopyOptions, + hooks: CopyHooks, elements: Element[], extraPrompt?: string, ): Promise => { let didCopy = false; let copiedContent = ""; - await options.onBeforeCopy?.(elements); + await hooks.onBeforeCopy(elements); try { if (options.getContent) { @@ -40,13 +52,13 @@ export const tryCopyWithFallback = async ( } catch (error) { const resolvedError = error instanceof Error ? error : new Error(String(error)); - options.onCopyError?.(resolvedError); + hooks.onCopyError(resolvedError); } if (didCopy) { - options.onCopySuccess?.(elements, copiedContent); + hooks.onCopySuccess(elements, copiedContent); } - options.onAfterCopy?.(elements, didCopy); + hooks.onAfterCopy(elements, didCopy); return didCopy; }; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 229ca4cf..4108c721 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -58,10 +58,11 @@ import type { AgentSession, ContextMenuActionContext, SettableOptions, + Plugin, } from "../types.js"; -import { mergeTheme } from "./theme.js"; +import { DEFAULT_THEME } from "./theme.js"; +import { createPluginRegistry } from "./plugin-registry.js"; import { createAgentManager } from "./agent/index.js"; -import { createOptionsStore } from "./options-store.js"; import { createArrowNavigator } from "./arrow-navigation.js"; import { getRequiredModifiers, @@ -92,8 +93,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ...rawOptions, }; - const mergedTheme = mergeTheme(initialOptions.theme); - if (initialOptions.enabled === false || hasInited) { return createNoopApi(); } @@ -102,16 +101,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { logIntro(); return createRoot((dispose) => { - const optionsStore = createOptionsStore({ - ...initialOptions, - theme: mergedTheme, - }); + const pluginRegistry = createPluginRegistry(initialOptions); const { store, actions } = createGrabStore({ - theme: mergedTheme, - hasAgentProvider: Boolean(optionsStore.store.agent?.provider), + theme: DEFAULT_THEME, + hasAgentProvider: Boolean(pluginRegistry.store.agent?.provider), keyHoldDuration: - optionsStore.store.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS, + pluginRegistry.store.options.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS, }); const isHoldingKeys = createMemo(() => store.current.state === "holding"); @@ -190,10 +186,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const currentlyActive = isActivated(); if (previouslyHoldingKeys && !currentlyHolding && currentlyActive) { - if (optionsStore.store.activationMode !== "hold") { + if (pluginRegistry.store.options.activationMode !== "hold") { actions.setWasActivatedByToggle(true); } - optionsStore.callbacks.onActivate?.(); + pluginRegistry.hooks.onActivate(); } previouslyHoldingKeys = currentlyHolding; }); @@ -260,7 +256,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const newBox: GrabbedBox = { id: boxId, bounds, createdAt, element }; actions.addGrabbedBox(newBox); - optionsStore.callbacks.onGrabbedBox?.(bounds, element); + pluginRegistry.hooks.onGrabbedBox(bounds, element); setTimeout(() => { actions.removeGrabbedBox(boxId); @@ -370,8 +366,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const copyWithFallback = (elements: Element[], extraPrompt?: string) => tryCopyWithFallback( { - ...optionsStore.store, - ...optionsStore.callbacks, + maxContextLines: pluginRegistry.store.options.maxContextLines, + getContent: pluginRegistry.store.options.getContent, + }, + { + onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, + onAfterCopy: pluginRegistry.hooks.onAfterCopy, + onCopySuccess: pluginRegistry.hooks.onCopySuccess, + onCopyError: pluginRegistry.hooks.onCopyError, }, elements, extraPrompt, @@ -384,8 +386,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (targetElements.length === 0) return; for (const element of targetElements) { - optionsStore.callbacks.onElementSelect?.(element); - if (optionsStore.store.theme.grabbedBoxes.enabled) { + pluginRegistry.hooks.onElementSelect(element); + if (pluginRegistry.store.theme.grabbedBoxes.enabled) { showTemporaryGrabbedBox(createElementBounds(element), element); } } @@ -557,7 +559,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setLastGrabbed(null); } if (currentElement) { - optionsStore.callbacks.onElementHover?.(currentElement); + pluginRegistry.hooks.onElementHover(currentElement); } }, ), @@ -614,7 +616,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { dragBounds(), ] as const, ([active, dragging, copying, inputMode, target, drag]) => { - optionsStore.callbacks.onStateChange?.({ + pluginRegistry.hooks.onStateChange({ isActive: active, isDragging: dragging, isCopying: copying, @@ -643,7 +645,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { targetElement(), ] as const, ([inputMode, x, y, target]) => { - optionsStore.callbacks.onPromptModeChange?.(inputMode, { + pluginRegistry.hooks.onPromptModeChange(inputMode, { x, y, targetElement: target, @@ -656,7 +658,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { on( () => [selectionVisible(), selectionBounds(), targetElement()] as const, ([visible, bounds, element]) => { - optionsStore.callbacks.onSelectionBox?.( + pluginRegistry.hooks.onSelectionBox( Boolean(visible), bounds ?? null, element, @@ -669,7 +671,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { on( () => [dragVisible(), dragBounds()] as const, ([visible, bounds]) => { - optionsStore.callbacks.onDragBox?.(Boolean(visible), bounds ?? null); + pluginRegistry.hooks.onDragBox(Boolean(visible), bounds ?? null); }, ), ); @@ -678,7 +680,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { on( () => [crosshairVisible(), store.pointer.x, store.pointer.y] as const, ([visible, x, y]) => { - optionsStore.callbacks.onCrosshair?.(Boolean(visible), { x, y }); + pluginRegistry.hooks.onCrosshair(Boolean(visible), { x, y }); }, ), ); @@ -687,7 +689,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { on( () => [labelVisible(), labelVariant(), cursorPosition()] as const, ([visible, variant, position]) => { - optionsStore.callbacks.onElementLabel?.(Boolean(visible), variant, { + pluginRegistry.hooks.onElementLabel(Boolean(visible), variant, { x: position.x, y: position.y, content: "", @@ -747,7 +749,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { // When coming from holding state, the reactive effect (previouslyHoldingKeys transition) // will handle calling onActivate to avoid duplicate invocations. if (!wasInHoldingState) { - optionsStore.callbacks.onActivate?.(); + pluginRegistry.hooks.onActivate(); } }; @@ -767,7 +769,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ) { previousFocused.focus(); } - optionsStore.callbacks.onDeactivate?.(); + pluginRegistry.hooks.onDeactivate(); }; const toggleActivate = () => { @@ -796,16 +798,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; const getAgentOptionsWithCallbacks = () => { - const agent = optionsStore.store.agent; + const agent = pluginRegistry.store.agent; if (!agent) return undefined; return { ...agent, onAbort: (session: AgentSession, elements: Element[]) => { - optionsStore.store.agent?.onAbort?.(session, elements); + pluginRegistry.store.agent?.onAbort?.(session, elements); restoreInputFromSession(session, elements); }, onUndo: (session: AgentSession, elements: Element[]) => { - optionsStore.store.agent?.onUndo?.(session, elements); + pluginRegistry.store.agent?.onUndo?.(session, elements); restoreInputFromSession(session, elements); }, }; @@ -1004,7 +1006,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.startDrag({ x: clientX, y: clientY }); document.body.style.userSelect = "none"; - optionsStore.callbacks.onDragStart?.( + pluginRegistry.hooks.onDragStart( clientX + window.scrollX, clientY + window.scrollY, ); @@ -1030,7 +1032,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (selectedElements.length === 0) return; - optionsStore.callbacks.onDragEnd?.(selectedElements, dragSelectionRect); + pluginRegistry.hooks.onDragEnd(selectedElements, dragSelectionRect); const firstElement = selectedElements[0]; const center = getBoundsCenter(createElementBounds(firstElement)); @@ -1328,9 +1330,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { event.preventDefault(); event.stopPropagation(); - if (optionsStore.callbacks.onOpenFile) { - optionsStore.callbacks.onOpenFile(filePath, lineNumber ?? undefined); - } else { + const wasHandled = pluginRegistry.hooks.onOpenFile(filePath, lineNumber ?? undefined); + if (!wasHandled) { const url = buildOpenFileUrl(filePath, lineNumber ?? undefined); window.open(url, "_blank", "noopener,noreferrer"); } @@ -1422,13 +1423,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const handleActivationKeys = (event: KeyboardEvent): void => { if ( - !optionsStore.store.allowActivationInsideInput && + !pluginRegistry.store.options.allowActivationInsideInput && isKeyboardEventTriggeredByInput(event) ) { return; } - if (!isTargetKeyCombination(event, optionsStore.store)) { + if (!isTargetKeyCombination(event, pluginRegistry.store.options)) { if ( isActivated() && !store.wasActivatedByToggle && @@ -1454,7 +1455,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (isActivated()) { if ( store.wasActivatedByToggle && - optionsStore.store.activationMode !== "hold" + pluginRegistry.store.options.activationMode !== "hold" ) return; if (event.repeat) return; @@ -1472,7 +1473,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!isHoldingKeys()) { const keyHoldDuration = - optionsStore.store.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS; + pluginRegistry.store.options.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS; const activationDuration = isKeyboardEventTriggeredByInput(event) ? keyHoldDuration + INPUT_FOCUS_ACTIVATION_DELAY_MS : keyHoldDuration; @@ -1492,7 +1493,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if ( isPromptMode() && - isTargetKeyCombination(event, optionsStore.store) && + isTargetKeyCombination(event, pluginRegistry.store.options) && !event.repeat ) { event.preventDefault(); @@ -1555,24 +1556,24 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (isPromptMode()) return; const hasCustomShortcut = Boolean( - optionsStore.store.activationShortcut || - optionsStore.store.activationKey, + pluginRegistry.store.options.activationShortcut || + pluginRegistry.store.options.activationKey, ); - const requiredModifiers = getRequiredModifiers(optionsStore.store); + const requiredModifiers = getRequiredModifiers(pluginRegistry.store.options); const isReleasingModifier = requiredModifiers.metaKey || requiredModifiers.ctrlKey ? !event.metaKey && !event.ctrlKey : (requiredModifiers.shiftKey && !event.shiftKey) || (requiredModifiers.altKey && !event.altKey); - const isReleasingActivationKey = optionsStore.store.activationShortcut - ? !optionsStore.store.activationShortcut(event) - : optionsStore.store.activationKey - ? optionsStore.store.activationKey.key + const isReleasingActivationKey = pluginRegistry.store.options.activationShortcut + ? !pluginRegistry.store.options.activationShortcut(event) + : pluginRegistry.store.options.activationKey + ? pluginRegistry.store.options.activationKey.key ? event.key?.toLowerCase() === - optionsStore.store.activationKey.key.toLowerCase() || - keyMatchesCode(optionsStore.store.activationKey.key, event.code) + pluginRegistry.store.options.activationKey.key.toLowerCase() || + keyMatchesCode(pluginRegistry.store.options.activationKey.key, event.code) : false : isCLikeKey(event.key, event.code); @@ -1580,7 +1581,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (isReleasingModifier) { if ( store.wasActivatedByToggle && - optionsStore.store.activationMode !== "hold" + pluginRegistry.store.options.activationMode !== "hold" ) return; deactivateRenderer(); @@ -1598,7 +1599,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (isReleasingActivationKey || isReleasingModifier) { if ( store.wasActivatedByToggle && - optionsStore.store.activationMode !== "hold" + pluginRegistry.store.options.activationMode !== "hold" ) return; if (isHoldingKeys()) { @@ -1698,7 +1699,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setFrozenElement(element); actions.freeze(); actions.showContextMenu(position, element); - optionsStore.callbacks.onContextMenu?.(element, position); + pluginRegistry.hooks.onContextMenu(element, position); }, { capture: true }, ); @@ -1854,7 +1855,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const startBoundsRecalcIntervalIfNeeded = () => { const shouldRunInterval = - optionsStore.store.theme.enabled && + pluginRegistry.store.theme.enabled && (isActivated() || isCopying() || store.labelInstances.length > 0 || @@ -1882,7 +1883,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; createEffect(() => { - void optionsStore.store.theme.enabled; + void pluginRegistry.store.theme.enabled; void isActivated(); void isCopying(); void store.labelInstances.length; @@ -1931,8 +1932,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { themeKey: "selectionBox" | "elementLabel", ) => createMemo(() => { - if (!optionsStore.store.theme.enabled) return false; - if (!optionsStore.store.theme[themeKey].enabled) return false; + if (!pluginRegistry.store.theme.enabled) return false; + if (!pluginRegistry.store.theme[themeKey].enabled) return false; if (didJustCopy()) return false; return ( isRendererActive() && !isDragging() && Boolean(effectiveElement()) @@ -1958,15 +1959,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const selectionLabelVisible = createMemo(() => { if (store.contextMenuPosition !== null) return false; - if (!optionsStore.store.theme.elementLabel.enabled) return false; + if (!pluginRegistry.store.theme.elementLabel.enabled) return false; if (didJustCopy()) return false; return isRendererActive() && !isDragging() && Boolean(effectiveElement()); }); const labelInstanceCache = new Map(); const computedLabelInstances = createMemo(() => { - if (!optionsStore.store.theme.enabled) return []; - if (!optionsStore.store.theme.grabbedBoxes.enabled) return []; + if (!pluginRegistry.store.theme.enabled) return []; + if (!pluginRegistry.store.theme.grabbedBoxes.enabled) return []; void store.viewportVersion; const currentIds = new Set(store.labelInstances.map((i) => i.id)); for (const cachedId of labelInstanceCache.keys()) { @@ -2001,8 +2002,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); const computedGrabbedBoxes = createMemo(() => { - if (!optionsStore.store.theme.enabled) return []; - if (!optionsStore.store.theme.grabbedBoxes.enabled) return []; + if (!pluginRegistry.store.theme.enabled) return []; + if (!pluginRegistry.store.theme.grabbedBoxes.enabled) return []; void store.viewportVersion; return store.grabbedBoxes.map((box) => { if (!box.element || !document.body.contains(box.element)) { @@ -2017,8 +2018,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const dragVisible = createMemo( () => - optionsStore.store.theme.enabled && - optionsStore.store.theme.dragBox.enabled && + pluginRegistry.store.theme.enabled && + pluginRegistry.store.theme.dragBox.enabled && isRendererActive() && isDraggingBeyondThreshold(), ); @@ -2028,8 +2029,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); const labelVisible = createMemo(() => { - if (!optionsStore.store.theme.enabled) return false; - const themeEnabled = optionsStore.store.theme.elementLabel.enabled; + if (!pluginRegistry.store.theme.enabled) return false; + const themeEnabled = pluginRegistry.store.theme.elementLabel.enabled; const inPromptMode = isPromptMode(); const copying = isCopying(); const rendererActive = isRendererActive(); @@ -2044,8 +2045,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const crosshairVisible = createMemo( () => - optionsStore.store.theme.enabled && - optionsStore.store.theme.crosshair.enabled && + pluginRegistry.store.theme.enabled && + pluginRegistry.store.theme.crosshair.enabled && isRendererActive() && !isDragging() && !store.isTouchMode && @@ -2216,12 +2217,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const handleContextMenuOpen = () => { const fileInfo = contextMenuFilePath(); if (fileInfo) { - if (optionsStore.callbacks.onOpenFile) { - optionsStore.callbacks.onOpenFile( - fileInfo.filePath, - fileInfo.lineNumber ?? undefined, - ); - } else { + const wasHandled = pluginRegistry.hooks.onOpenFile(fileInfo.filePath, fileInfo.lineNumber ?? undefined); + if (!wasHandled) { const openFileUrl = buildOpenFileUrl( fileInfo.filePath, fileInfo.lineNumber, @@ -2321,7 +2318,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; createEffect(() => { - const hue = optionsStore.store.theme.hue; + const hue = pluginRegistry.store.theme.hue; if (hue !== 0) { rendererRoot.style.filter = `hue-rotate(${hue}deg)`; } else { @@ -2329,7 +2326,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } }); - if (optionsStore.store.theme.enabled) { + if (pluginRegistry.store.theme.enabled) { render( () => ( { actions.setPendingAbortSessionId(sessionId) } onAbortSession={handleAgentAbort} - theme={optionsStore.store.theme} - toolbarVisible={optionsStore.store.theme.toolbar.enabled} + theme={pluginRegistry.store.theme} + toolbarVisible={pluginRegistry.store.theme.toolbar.enabled} isActive={isActivated()} onToggleActive={handleToggleActive} contextMenuPosition={contextMenuPosition()} @@ -2388,7 +2385,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { contextMenuComponentName={contextMenuComponentName()} contextMenuHasFilePath={Boolean(contextMenuFilePath()?.filePath)} contextMenuHasAgent={hasAgentProvider()} - contextMenuActions={optionsStore.store.contextMenuActions} + contextMenuActions={pluginRegistry.store.contextMenuActions} contextMenuActionContext={contextMenuActionContext()} onContextMenuCopy={handleContextMenuCopy} onContextMenuCopyScreenshot={() => @@ -2415,7 +2412,53 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return await copyWithFallback(elementsArray); }; - return { + const syncAgentFromRegistry = () => { + const agentOpts = getAgentOptionsWithCallbacks(); + if (agentOpts) { + agentManager._internal.setOptions(agentOpts); + } + actions.setHasAgentProvider(Boolean(agentOpts?.provider)); + if (agentOpts?.provider) { + const capturedProvider = agentOpts.provider; + actions.setAgentCapabilities({ + supportsUndo: Boolean(capturedProvider.undo), + supportsFollowUp: Boolean(capturedProvider.supportsFollowUp), + dismissButtonText: capturedProvider.dismissButtonText, + isAgentConnected: false, + }); + + if (capturedProvider.checkConnection) { + capturedProvider + .checkConnection() + .then((isConnected) => { + const currentAgentOpts = getAgentOptionsWithCallbacks(); + if (currentAgentOpts?.provider !== capturedProvider) { + return; + } + actions.setAgentCapabilities({ + supportsUndo: Boolean(capturedProvider.undo), + supportsFollowUp: Boolean(capturedProvider.supportsFollowUp), + dismissButtonText: capturedProvider.dismissButtonText, + isAgentConnected: isConnected, + }); + }) + .catch(() => { + // Connection check failed - leave isAgentConnected as false + }); + } + + agentManager.session.tryResume(); + } else { + actions.setAgentCapabilities({ + supportsUndo: false, + supportsFollowUp: false, + dismissButtonText: undefined, + isAgentConnected: false, + }); + } + }; + + const api: ReactGrabAPI = { activate: () => { if (!isActivated()) { toggleActivate(); @@ -2448,40 +2491,20 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { dragBounds: dragBounds() ?? null, }), setOptions: (newOptions: SettableOptions) => { - optionsStore.setOptions(newOptions); - - if (newOptions.agent !== undefined) { - const agentOpts = getAgentOptionsWithCallbacks(); - if (agentOpts) { - agentManager._internal.setOptions(agentOpts); - } - actions.setHasAgentProvider(Boolean(agentOpts?.provider)); - if (agentOpts?.provider) { - actions.setAgentCapabilities({ - supportsUndo: Boolean(agentOpts.provider.undo), - supportsFollowUp: Boolean(agentOpts.provider.supportsFollowUp), - dismissButtonText: agentOpts.provider.dismissButtonText, - isAgentConnected: false, - }); - - if (agentOpts.provider.checkConnection) { - void agentOpts.provider.checkConnection().then((connected) => { - actions.setAgentCapabilities({ - supportsUndo: Boolean(agentOpts.provider?.undo), - supportsFollowUp: Boolean( - agentOpts.provider?.supportsFollowUp, - ), - dismissButtonText: agentOpts.provider?.dismissButtonText, - isAgentConnected: connected, - }); - }); - } - - agentManager.session.tryResume(); - } - } + pluginRegistry.setOptions(newOptions); + }, + registerPlugin: (plugin: Plugin) => { + pluginRegistry.register(plugin, api); + syncAgentFromRegistry(); }, + unregisterPlugin: (name: string) => { + pluginRegistry.unregister(name); + syncAgentFromRegistry(); + }, + getPlugins: () => pluginRegistry.getPluginNames(), }; + + return api; }); }; @@ -2503,6 +2526,9 @@ export type { SettableOptions, ContextMenuAction, ContextMenuActionContext, + Plugin, + PluginConfig, + PluginHooks, } from "../types.js"; export { generateSnippet } from "../utils/generate-snippet.js"; diff --git a/packages/react-grab/src/core/noop-api.ts b/packages/react-grab/src/core/noop-api.ts index bdd974bb..a2f9eb4e 100644 --- a/packages/react-grab/src/core/noop-api.ts +++ b/packages/react-grab/src/core/noop-api.ts @@ -21,5 +21,8 @@ export const createNoopApi = (): ReactGrabAPI => { copyElement: () => Promise.resolve(false), getState, setOptions: () => {}, + registerPlugin: () => {}, + unregisterPlugin: () => {}, + getPlugins: () => [], }; }; diff --git a/packages/react-grab/src/core/options-store.ts b/packages/react-grab/src/core/options-store.ts deleted file mode 100644 index c11ec881..00000000 --- a/packages/react-grab/src/core/options-store.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { createStore } from "solid-js/store"; -import type { - ActivationMode, - ActivationKey, - AgentOptions, - ReactGrabState, - OverlayBounds, - ElementLabelVariant, - PromptModeContext, - CrosshairContext, - ElementLabelContext, - DragRect, - ContextMenuAction, - Theme, - DeepPartial, -} from "../types.js"; -import { DEFAULT_KEY_HOLD_DURATION_MS } from "../constants.js"; -import { deepMergeTheme } from "./theme.js"; - -interface OptionsStoreState { - activationMode: ActivationMode; - keyHoldDuration: number; - allowActivationInsideInput: boolean; - maxContextLines: number; - activationShortcut: ((event: KeyboardEvent) => boolean) | undefined; - activationKey: ActivationKey | undefined; - getContent: ((elements: Element[]) => Promise | string) | undefined; - contextMenuActions: ContextMenuAction[]; - agent: AgentOptions | undefined; - theme: Required; -} - -interface CallbacksState { - onActivate: (() => void) | undefined; - onDeactivate: (() => void) | undefined; - onElementHover: ((element: Element) => void) | undefined; - onElementSelect: ((element: Element) => void) | undefined; - onDragStart: ((startX: number, startY: number) => void) | undefined; - onDragEnd: ((elements: Element[], bounds: DragRect) => void) | undefined; - onBeforeCopy: ((elements: Element[]) => void | Promise) | undefined; - onAfterCopy: ((elements: Element[], success: boolean) => void) | undefined; - onCopySuccess: ((elements: Element[], content: string) => void) | undefined; - onCopyError: ((error: Error) => void) | undefined; - onStateChange: ((state: ReactGrabState) => void) | undefined; - onPromptModeChange: - | ((isPromptMode: boolean, context: PromptModeContext) => void) - | undefined; - onSelectionBox: - | (( - visible: boolean, - bounds: OverlayBounds | null, - element: Element | null, - ) => void) - | undefined; - onDragBox: - | ((visible: boolean, bounds: OverlayBounds | null) => void) - | undefined; - onGrabbedBox: - | ((bounds: OverlayBounds, element: Element) => void) - | undefined; - onElementLabel: - | (( - visible: boolean, - variant: ElementLabelVariant, - context: ElementLabelContext, - ) => void) - | undefined; - onCrosshair: - | ((visible: boolean, context: CrosshairContext) => void) - | undefined; - onContextMenu: - | ((element: Element, position: { x: number; y: number }) => void) - | undefined; - onOpenFile: ((filePath: string, lineNumber?: number) => void) | undefined; -} - -const isCallbackKey = (key: string): boolean => key.startsWith("on"); - -interface OptionsStoreInput { - activationMode?: ActivationMode; - keyHoldDuration?: number; - allowActivationInsideInput?: boolean; - maxContextLines?: number; - activationShortcut?: (event: KeyboardEvent) => boolean; - activationKey?: ActivationKey; - getContent?: (elements: Element[]) => Promise | string; - contextMenuActions?: ContextMenuAction[]; - agent?: AgentOptions; - theme: Required; - onActivate?: () => void; - onDeactivate?: () => void; - onElementHover?: (element: Element) => void; - onElementSelect?: (element: Element) => void; - onDragStart?: (startX: number, startY: number) => void; - onDragEnd?: (elements: Element[], bounds: DragRect) => void; - onBeforeCopy?: (elements: Element[]) => void | Promise; - onAfterCopy?: (elements: Element[], success: boolean) => void; - onCopySuccess?: (elements: Element[], content: string) => void; - onCopyError?: (error: Error) => void; - onStateChange?: (state: ReactGrabState) => void; - onPromptModeChange?: ( - isPromptMode: boolean, - context: PromptModeContext, - ) => void; - onSelectionBox?: ( - visible: boolean, - bounds: OverlayBounds | null, - element: Element | null, - ) => void; - onDragBox?: (visible: boolean, bounds: OverlayBounds | null) => void; - onGrabbedBox?: (bounds: OverlayBounds, element: Element) => void; - onElementLabel?: ( - visible: boolean, - variant: ElementLabelVariant, - context: ElementLabelContext, - ) => void; - onCrosshair?: (visible: boolean, context: CrosshairContext) => void; - onContextMenu?: ( - element: Element, - position: { x: number; y: number }, - ) => void; - onOpenFile?: (filePath: string, lineNumber?: number) => void; -} - -const createOptionsStore = (initialOptions: OptionsStoreInput) => { - const [optionsState, setOptionsState] = createStore({ - activationMode: initialOptions.activationMode ?? "toggle", - keyHoldDuration: initialOptions.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS, - allowActivationInsideInput: initialOptions.allowActivationInsideInput ?? true, - maxContextLines: initialOptions.maxContextLines ?? 3, - activationShortcut: initialOptions.activationShortcut, - activationKey: initialOptions.activationKey, - getContent: initialOptions.getContent, - contextMenuActions: initialOptions.contextMenuActions ?? [], - agent: initialOptions.agent, - theme: initialOptions.theme, - }); - - // HACK: store callbacks in a regular object to avoid SolidJS store proxy issues with functions - const callbackHandlers = Object.fromEntries( - Object.entries(initialOptions).filter(([optionKey]) => isCallbackKey(optionKey)), - ) as CallbacksState; - - const setOptions = (optionUpdates: Partial> & { theme?: DeepPartial; agent?: Partial }) => { - if (optionUpdates.theme) setOptionsState("theme", deepMergeTheme(optionsState.theme, optionUpdates.theme)); - if (optionUpdates.agent) setOptionsState("agent", { ...optionsState.agent, ...optionUpdates.agent }); - - for (const [optionKey, optionValue] of Object.entries(optionUpdates)) { - if (optionKey === "theme" || optionKey === "agent" || optionValue === undefined) continue; - - if (isCallbackKey(optionKey)) { - Object.assign(callbackHandlers, { [optionKey]: optionValue }); - } else { - setOptionsState(optionKey as keyof OptionsStoreState, optionValue as OptionsStoreState[keyof OptionsStoreState]); - } - } - }; - - return { - store: optionsState, - callbacks: callbackHandlers, - setStore: setOptionsState, - setOptions, - }; -}; - -export { createOptionsStore }; -export type { OptionsStoreState, OptionsStoreInput, CallbacksState }; diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts new file mode 100644 index 00000000..6d5a774e --- /dev/null +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -0,0 +1,266 @@ +import { createStore } from "solid-js/store"; +import type { + Plugin, + PluginConfig, + PluginHooks, + Theme, + AgentOptions, + ContextMenuAction, + ReactGrabState, + PromptModeContext, + OverlayBounds, + DragRect, + ElementLabelVariant, + ElementLabelContext, + CrosshairContext, + ActivationMode, + ActivationKey, + SettableOptions, +} from "../types.js"; +import { DEFAULT_THEME, deepMergeTheme } from "./theme.js"; +import { DEFAULT_KEY_HOLD_DURATION_MS } from "../constants.js"; + +interface RegisteredPlugin { + plugin: Plugin; + config: PluginConfig; +} + +interface OptionsState { + activationMode: ActivationMode; + keyHoldDuration: number; + allowActivationInsideInput: boolean; + maxContextLines: number; + activationShortcut: ((event: KeyboardEvent) => boolean) | undefined; + activationKey: ActivationKey | undefined; + getContent: ((elements: Element[]) => Promise | string) | undefined; +} + +const DEFAULT_OPTIONS: OptionsState = { + activationMode: "toggle", + keyHoldDuration: DEFAULT_KEY_HOLD_DURATION_MS, + allowActivationInsideInput: true, + maxContextLines: 3, + activationShortcut: undefined, + activationKey: undefined, + getContent: undefined, +}; + +interface PluginStoreState { + theme: Required; + agent: AgentOptions | undefined; + options: OptionsState; + contextMenuActions: ContextMenuAction[]; +} + +type HookName = keyof PluginHooks; + +const createPluginRegistry = (initialOptions: SettableOptions = {}) => { + const plugins = new Map(); + const directOptionOverrides: Partial = {}; + + const [store, setStore] = createStore({ + theme: DEFAULT_THEME, + agent: undefined, + options: { ...DEFAULT_OPTIONS, ...initialOptions }, + contextMenuActions: [], + }); + + const recomputeStore = () => { + let mergedTheme: Required = DEFAULT_THEME; + let mergedAgent: AgentOptions | undefined = undefined; + let mergedOptions: OptionsState = { ...DEFAULT_OPTIONS, ...initialOptions }; + const allContextMenuActions: ContextMenuAction[] = []; + + for (const { config } of plugins.values()) { + if (config.theme) { + mergedTheme = deepMergeTheme(mergedTheme, config.theme); + } + + if (config.agent) { + const agentConfig = config.agent as AgentOptions; + if (mergedAgent) { + mergedAgent = Object.assign({}, mergedAgent, agentConfig); + } else { + mergedAgent = agentConfig; + } + } + + if (config.options) { + mergedOptions = { ...mergedOptions, ...config.options }; + } + + if (config.contextMenuActions) { + allContextMenuActions.push(...config.contextMenuActions); + } + } + + mergedOptions = { ...mergedOptions, ...directOptionOverrides }; + + setStore("theme", mergedTheme); + setStore("agent", mergedAgent); + setStore("options", mergedOptions); + setStore("contextMenuActions", allContextMenuActions); + }; + + const setOptions = (optionUpdates: SettableOptions) => { + for (const [optionKey, optionValue] of Object.entries(optionUpdates)) { + if (optionValue === undefined) continue; + (directOptionOverrides as Record)[optionKey] = optionValue; + setStore("options", optionKey as keyof OptionsState, optionValue as OptionsState[keyof OptionsState]); + } + }; + + const register = (plugin: Plugin, api: unknown) => { + if (plugins.has(plugin.name)) { + unregister(plugin.name); + } + + let config: PluginConfig; + + if (plugin.setup) { + const setupResult = plugin.setup(api as Parameters>[0]); + config = setupResult ?? {}; + } else { + config = {}; + } + + if (plugin.theme) { + config.theme = config.theme + ? deepMergeTheme( + deepMergeTheme(DEFAULT_THEME, plugin.theme), + config.theme, + ) + : plugin.theme; + } + + if (plugin.agent) { + config.agent = config.agent + ? { ...plugin.agent, ...config.agent } + : plugin.agent; + } + + if (plugin.contextMenuActions) { + config.contextMenuActions = [ + ...plugin.contextMenuActions, + ...(config.contextMenuActions ?? []), + ]; + } + + if (plugin.hooks) { + config.hooks = config.hooks + ? { ...plugin.hooks, ...config.hooks } + : plugin.hooks; + } + + if (plugin.options) { + config.options = config.options + ? { ...plugin.options, ...config.options } + : plugin.options; + } + + plugins.set(plugin.name, { plugin, config }); + recomputeStore(); + return config; + }; + + const unregister = (name: string) => { + const registered = plugins.get(name); + if (!registered) return; + + if (registered.config.cleanup) { + registered.config.cleanup(); + } + + plugins.delete(name); + recomputeStore(); + }; + + const getPluginNames = (): string[] => { + return Array.from(plugins.keys()); + }; + + const callHook = ( + hookName: K, + ...args: Parameters> + ): void => { + for (const { config } of plugins.values()) { + const hook = config.hooks?.[hookName] as + | ((...hookArgs: Parameters>) => void) + | undefined; + if (hook) { + hook(...args); + } + } + }; + + const callHookWithHandled = ( + hookName: K, + ...args: Parameters> + ): boolean => { + let handled = false; + for (const { config } of plugins.values()) { + const hook = config.hooks?.[hookName] as + | ((...hookArgs: Parameters>) => boolean | void) + | undefined; + if (hook) { + const result = hook(...args); + if (result === true) { + handled = true; + } + } + } + return handled; + }; + + const callHookAsync = async ( + hookName: K, + ...args: Parameters> + ): Promise => { + for (const { config } of plugins.values()) { + const hook = config.hooks?.[hookName] as + | ((...hookArgs: Parameters>) => ReturnType>) + | undefined; + if (hook) { + await hook(...args); + } + } + }; + + const hooks = { + onActivate: () => callHook("onActivate"), + onDeactivate: () => callHook("onDeactivate"), + onElementHover: (element: Element) => callHook("onElementHover", element), + onElementSelect: (element: Element) => callHook("onElementSelect", element), + onDragStart: (startX: number, startY: number) => callHook("onDragStart", startX, startY), + onDragEnd: (elements: Element[], bounds: DragRect) => callHook("onDragEnd", elements, bounds), + onBeforeCopy: async (elements: Element[]) => callHookAsync("onBeforeCopy", elements), + onAfterCopy: (elements: Element[], success: boolean) => callHook("onAfterCopy", elements, success), + onCopySuccess: (elements: Element[], content: string) => callHook("onCopySuccess", elements, content), + onCopyError: (error: Error) => callHook("onCopyError", error), + onStateChange: (state: ReactGrabState) => callHook("onStateChange", state), + onPromptModeChange: (isPromptMode: boolean, context: PromptModeContext) => + callHook("onPromptModeChange", isPromptMode, context), + onSelectionBox: (visible: boolean, bounds: OverlayBounds | null, element: Element | null) => + callHook("onSelectionBox", visible, bounds, element), + onDragBox: (visible: boolean, bounds: OverlayBounds | null) => callHook("onDragBox", visible, bounds), + onGrabbedBox: (bounds: OverlayBounds, element: Element) => callHook("onGrabbedBox", bounds, element), + onElementLabel: (visible: boolean, variant: ElementLabelVariant, context: ElementLabelContext) => + callHook("onElementLabel", visible, variant, context), + onCrosshair: (visible: boolean, context: CrosshairContext) => callHook("onCrosshair", visible, context), + onContextMenu: (element: Element, position: { x: number; y: number }) => + callHook("onContextMenu", element, position), + onOpenFile: (filePath: string, lineNumber?: number) => callHookWithHandled("onOpenFile", filePath, lineNumber), + }; + + return { + register, + unregister, + getPluginNames, + setOptions, + store, + hooks, + }; +}; + +export { createPluginRegistry }; +export type { OptionsState }; diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index 80c948c8..17bfa2dc 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -30,6 +30,9 @@ export type { ActivationMode, ContextMenuAction, ContextMenuActionContext, + Plugin, + PluginConfig, + PluginHooks, } from "./types.js"; import { init } from "./core/index.js"; diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 0e27d8a1..0e00de99 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -207,16 +207,7 @@ export interface ContextMenuAction { onAction: (context: ContextMenuActionContext) => void; } -export interface Options { - enabled?: boolean; - activationMode?: ActivationMode; - keyHoldDuration?: number; - allowActivationInsideInput?: boolean; - maxContextLines?: number; - theme?: Theme; - activationShortcut?: (event: KeyboardEvent) => boolean; - activationKey?: ActivationKey; - getContent?: (elements: Element[]) => Promise | string; +export interface PluginHooks { onActivate?: () => void; onDeactivate?: () => void; onElementHover?: (element: Element) => void; @@ -228,36 +219,47 @@ export interface Options { onCopySuccess?: (elements: Element[], content: string) => void; onCopyError?: (error: Error) => void; onStateChange?: (state: ReactGrabState) => void; - onPromptModeChange?: ( - isPromptMode: boolean, - context: PromptModeContext, - ) => void; - onSelectionBox?: ( - visible: boolean, - bounds: OverlayBounds | null, - element: Element | null, - ) => void; + onPromptModeChange?: (isPromptMode: boolean, context: PromptModeContext) => void; + onSelectionBox?: (visible: boolean, bounds: OverlayBounds | null, element: Element | null) => void; onDragBox?: (visible: boolean, bounds: OverlayBounds | null) => void; onGrabbedBox?: (bounds: OverlayBounds, element: Element) => void; - onElementLabel?: ( - visible: boolean, - variant: ElementLabelVariant, - context: ElementLabelContext, - ) => void; + onElementLabel?: (visible: boolean, variant: ElementLabelVariant, context: ElementLabelContext) => void; onCrosshair?: (visible: boolean, context: CrosshairContext) => void; - onContextMenu?: ( - element: Element, - position: { x: number; y: number }, - ) => void; - onOpenFile?: (filePath: string, lineNumber?: number) => void; - agent?: AgentOptions; + onContextMenu?: (element: Element, position: { x: number; y: number }) => void; + onOpenFile?: (filePath: string, lineNumber?: number) => boolean | void; +} + +export interface PluginConfig { + theme?: DeepPartial; + agent?: Partial; + options?: SettableOptions; contextMenuActions?: ContextMenuAction[]; + hooks?: PluginHooks; + cleanup?: () => void; } -export type SettableOptions = Omit & { +export interface Plugin { + name: string; theme?: DeepPartial; agent?: Partial; -}; + options?: SettableOptions; + contextMenuActions?: ContextMenuAction[]; + hooks?: PluginHooks; + setup?: (api: ReactGrabAPI) => PluginConfig | void; +} + +export interface Options { + enabled?: boolean; + activationMode?: ActivationMode; + keyHoldDuration?: number; + allowActivationInsideInput?: boolean; + maxContextLines?: number; + activationShortcut?: (event: KeyboardEvent) => boolean; + activationKey?: ActivationKey; + getContent?: (elements: Element[]) => Promise | string; +} + +export type SettableOptions = Omit; export interface ReactGrabAPI { activate: () => void; @@ -268,6 +270,9 @@ export interface ReactGrabAPI { copyElement: (elements: Element | Element[]) => Promise; getState: () => ReactGrabState; setOptions: (options: SettableOptions) => void; + registerPlugin: (plugin: Plugin) => void; + unregisterPlugin: (name: string) => void; + getPlugins: () => string[]; } export interface OverlayBounds { diff --git a/packages/website/components/grab-element-button.tsx b/packages/website/components/grab-element-button.tsx index 0fe79309..95ac9895 100644 --- a/packages/website/components/grab-element-button.tsx +++ b/packages/website/components/grab-element-button.tsx @@ -68,11 +68,16 @@ const updateReactGrabHotkey = (hotkey: RecordedHotkey | null) => { : undefined; const newApi = reactGrab.init({ activationKey, - onActivate: () => { - window.dispatchEvent(new CustomEvent("react-grab:activated")); - }, - onDeactivate: () => { - window.dispatchEvent(new CustomEvent("react-grab:deactivated")); + }); + newApi.registerPlugin({ + name: "website-events", + hooks: { + onActivate: () => { + window.dispatchEvent(new CustomEvent("react-grab:activated")); + }, + onDeactivate: () => { + window.dispatchEvent(new CustomEvent("react-grab:deactivated")); + }, }, }); reactGrab.setGlobalApi(newApi); diff --git a/packages/website/instrumentation-client.ts b/packages/website/instrumentation-client.ts index fafac1e0..7d1f2c63 100644 --- a/packages/website/instrumentation-client.ts +++ b/packages/website/instrumentation-client.ts @@ -19,12 +19,17 @@ const isUserInAbusiveRegion = (): boolean => { }; if (typeof window !== "undefined" && !window.__REACT_GRAB__) { - const api = init({ - onActivate: () => { - window.dispatchEvent(new CustomEvent("react-grab:activated")); - }, - onDeactivate: () => { - window.dispatchEvent(new CustomEvent("react-grab:deactivated")); + const api = init(); + + api.registerPlugin({ + name: "website-events", + hooks: { + onActivate: () => { + window.dispatchEvent(new CustomEvent("react-grab:activated")); + }, + onDeactivate: () => { + window.dispatchEvent(new CustomEvent("react-grab:deactivated")); + }, }, }); @@ -33,7 +38,8 @@ if (typeof window !== "undefined" && !window.__REACT_GRAB__) { const { provider, getOptions, onStart, onComplete, onUndo } = createVisualEditAgentProvider({ apiEndpoint: "/api/visual-edit" }); - api.setOptions({ + api.registerPlugin({ + name: "visual-edit-agent", agent: { provider, getOptions,