From f766c84c663a240e7b082761aead311a3fa5bb68 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 26 Aug 2026 20:14:51 -0400 Subject: [PATCH] feat(studio): let an agent edit text and styles, guarded The first tools that change the composition. Both act on the current selection and take no handle, which is forced rather than chosen: the handlers read the ambient React selection, and `applyDomSelection` only schedules a state update, so selecting and committing inside one call would write to whatever was selected before. Select first, then edit. Also plumbs the write-blocked state, which was the blocker for shipping any write at all. `domEditSaveQueuePaused` and the external-file conflict both lived on App and were unreachable from the tool surface, so `canWrite` was optimistic and a comment said so. They now derive into a single `writeBlockedReason` on the shell context: one field, one owner, conflict taking precedence because resolving it is what unblocks the queue. That guard matters more than it looks. Both states are BANNERS in Studio with no lock behind them, so nothing else was stopping a programmatic write from landing on top of a conflict the user had been asked to adjudicate. Three things the tools refuse to fake: They check the outcome, not the absence of a throw. Studio has several paths where a failed commit resolves anyway, so awaiting the handler proves nothing. The tagged outcome added earlier is what proves the write landed. A partial style result is reported as partial. `handleDomStyleCommit` is one property per call, so N properties are N commits; the result carries `applied` and `rejected` maps rather than a single boolean that would have to pick a side. Style commits run sequentially, never concurrently. Two commits racing through Studio's client-side read-modify-write can record undo entries that both claim the same starting content. There is a test that measures concurrency rather than trusting the loop. Every decline reason maps to a hint naming what to do instead, so a refusal routes the agent rather than just stopping it. --- packages/studio/src/App.tsx | 2 + .../studio/src/contexts/StudioContext.tsx | 10 + .../studio/src/hooks/useStudioContextValue.ts | 9 + .../studio/src/webmcp/StudioAgentTools.tsx | 17 +- .../src/webmcp/tools/contentTools.test.ts | 201 ++++++++++++++++++ .../studio/src/webmcp/tools/contentTools.ts | 184 ++++++++++++++++ .../src/webmcp/useStudioAgentTools.test.tsx | 11 +- .../studio/src/webmcp/useStudioAgentTools.ts | 32 ++- 8 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 packages/studio/src/webmcp/tools/contentTools.test.ts create mode 100644 packages/studio/src/webmcp/tools/contentTools.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6b23e67524..4da005b94e 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -433,6 +433,8 @@ export function StudioApp() { handleRedo: appHotkeys.handleRedo, renderQueue, compositionDimensions, + domEditSaveQueuePaused: previewPersistence.domEditSaveQueuePaused, + externalFileConflict: externalFileChanges.blocked !== null, waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves, handlePreviewIframeRef, refreshPreviewDocumentVersion, diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 97b8ac0be3..291219838a 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -16,6 +16,13 @@ export interface StudioShellValue { undoLabel: string | undefined; redoLabel: string | undefined; }; + /** + * Why a composition write would be refused right now, or null when writes + * are possible. Derived from the paused save queue and the external-file + * conflict state, both of which are otherwise banners with no lock behind + * them. One field rather than two, so there is one owner of the question. + */ + writeBlockedReason: string | null; handleUndo: () => Promise; handleRedo: () => Promise; renderQueue: { @@ -106,6 +113,7 @@ export function StudioShellProvider({ showToast, previewIframeRef, editHistory, + writeBlockedReason, handleUndo, handleRedo, renderQueue, @@ -122,6 +130,7 @@ export function StudioShellProvider({ showToast, previewIframeRef, editHistory, + writeBlockedReason, handleUndo, handleRedo, renderQueue, @@ -138,6 +147,7 @@ export function StudioShellProvider({ setActiveCompPath, showToast, previewIframeRef, + writeBlockedReason, handleUndo, handleRedo, waitForPendingDomEditSaves, diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 9553d82d0c..1f1250c4fc 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -25,6 +25,10 @@ interface StudioContextInput { // fields around it: the context type owns it. renderQueue: StudioContextValue["renderQueue"]; compositionDimensions: { width: number; height: number } | null; + /** Message from `usePreviewPersistence` when auto-save is paused. */ + domEditSaveQueuePaused: string | null; + /** True when an external edit to the open file is awaiting the user's decision. */ + externalFileConflict: boolean; waitForPendingDomEditSaves: () => Promise; handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void; refreshPreviewDocumentVersion: () => void; @@ -46,6 +50,11 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex timelineElements: input.timelineElements, isPlaying: input.isPlaying, editHistory: input.editHistory, + // Conflict first: when both are true the conflict is the one the user has + // been asked to decide, and resolving it is what unblocks the queue. + writeBlockedReason: input.externalFileConflict + ? "an external change to this file is waiting to be resolved" + : input.domEditSaveQueuePaused, handleUndo: input.handleUndo, handleRedo: input.handleRedo, renderQueue: input.renderQueue, diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx index 4fbf55e689..c52ee655b1 100644 --- a/packages/studio/src/webmcp/StudioAgentTools.tsx +++ b/packages/studio/src/webmcp/StudioAgentTools.tsx @@ -17,15 +17,20 @@ import type { StudioLookSnapshot } from "./tools/lookTools"; * every animation frame during playback for a value nothing here displays. */ export function StudioAgentTools() { - const { projectId, activeCompPath, editHistory } = useStudioShellContext(); + const { projectId, activeCompPath, editHistory, writeBlockedReason } = useStudioShellContext(); const { domEditSelection, selectedGsapAnimations, gsapMultipleTimelines, gsapUnsupportedTimelinePattern, } = useDomEditSelectionContext(); - const { previewIframeRef, buildDomSelectionFromTarget, applyDomSelection } = - useDomEditActionsContext(); + const { + previewIframeRef, + buildDomSelectionFromTarget, + applyDomSelection, + handleDomTextCommit, + handleDomStyleCommit, + } = useDomEditActionsContext(); const getSnapshot = useCallback((): StudioLookSnapshot => { const player = usePlayerStore.getState(); @@ -77,6 +82,9 @@ export function StudioAgentTools() { }, wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), getCurrentSelection: () => domEditSelection, + getWriteBlockedReason: () => writeBlockedReason, + setText: (value, fieldKey) => handleDomTextCommit(value, fieldKey), + setStyle: (property, value) => handleDomStyleCommit(property, value), getGsapDiagnostics: () => ({ animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, @@ -90,6 +98,9 @@ export function StudioAgentTools() { applyDomSelection, projectId, activeCompPath, + writeBlockedReason, + handleDomTextCommit, + handleDomStyleCommit, domEditSelection, selectedGsapAnimations, gsapMultipleTimelines, diff --git a/packages/studio/src/webmcp/tools/contentTools.test.ts b/packages/studio/src/webmcp/tools/contentTools.test.ts new file mode 100644 index 0000000000..7b5d825ade --- /dev/null +++ b/packages/studio/src/webmcp/tools/contentTools.test.ts @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { + studioSetStyle, + studioSetText, + type ContentToolDeps, + type StudioSetStyleResult, + type StudioSetTextResult, +} from "./contentTools"; +import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils"; + +function contentDeps(overrides: Partial = {}): ContentToolDeps { + const element = previewElement('

Ship it

', "headline"); + return { + getCurrentSelection: () => selectionFor(element), + getWriteBlockedReason: () => null, + setText: async () => ({ ok: true }), + setStyle: async () => ({ ok: true }), + ...overrides, + }; +} + +describe("studioSetText", () => { + it("writes the text and reports what it now is", async () => { + const setText = vi.fn(async () => ({ ok: true }) as const); + + const result = await studioSetText(contentDeps({ setText }), { text: "Ship it faster" }); + + const ok = expectOk(result); + expect(ok.text).toBe("Ship it faster"); + expect(ok.changed).toBe(true); + expect(setText).toHaveBeenCalledWith("Ship it faster", undefined); + }); + + it("reports changed:false when the text already said that", async () => { + const result = await studioSetText(contentDeps(), { text: "Ship it" }); + + expect(expectOk(result).changed).toBe(false); + }); + + it("refuses to write while a conflict is waiting for the user", async () => { + // The paused-save and conflict states are banners with no lock behind them. + // Nothing else stops a programmatic write landing on top of a decision the + // user has been asked to make. + const setText = vi.fn(); + + const result = expectFailure( + await studioSetText( + contentDeps({ + getWriteBlockedReason: () => "an external change to this file is waiting to be resolved", + setText, + }), + { text: "Ship it faster" }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(result.reason).toMatch(/external change/); + expect(setText).not.toHaveBeenCalled(); + }); + + it("does not report success when the commit declined", async () => { + // The whole reason the handlers now return an outcome: they resolve on + // failure, so awaiting them proves nothing. + const result = expectFailure( + await studioSetText( + contentDeps({ setText: async () => ({ ok: false, reason: "persist-failed" }) }), + { text: "Ship it faster" }, + ), + ); + + expect(result.kind).toBe("failed"); + expect(result.reason).toMatch(/persist-failed/); + }); + + it("turns a decline reason into a hint naming what to do instead", async () => { + const result = expectFailure( + await studioSetText( + contentDeps({ setText: async () => ({ ok: false, reason: "not-text-editable" }) }), + { text: "x" }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(result.hint).toMatch(/studio_inspect/); + }); + + it("rejects a non-string text without dispatching", async () => { + const setText = vi.fn(); + + const result = expectFailure(await studioSetText(contentDeps({ setText }), { text: 42 })); + + expect(result.kind).toBe("invalid"); + expect(setText).not.toHaveBeenCalled(); + }); + + it("fails when nothing is selected", async () => { + const setText = vi.fn(); + + const result = expectFailure( + await studioSetText(contentDeps({ getCurrentSelection: () => null, setText }), { text: "x" }), + ); + + expect(result.kind).toBe("invalid"); + expect(result.hint).toMatch(/studio_select/); + expect(setText).not.toHaveBeenCalled(); + }); +}); + +describe("studioSetStyle", () => { + it("applies every property and reports them", async () => { + const setStyle = vi.fn(async () => ({ ok: true }) as const); + + const result = await studioSetStyle(contentDeps({ setStyle }), { + styles: { color: "red", "font-size": "48px" }, + }); + + const ok = expectOk(result); + expect(ok.applied).toEqual({ color: "red", "font-size": "48px" }); + expect(ok.rejected).toEqual({}); + expect(setStyle).toHaveBeenCalledTimes(2); + }); + + it("commits sequentially, never concurrently", async () => { + // Two commits racing through Studio's client-side read-modify-write can + // record undo entries that both claim the same starting content. + let inFlight = 0; + let maxInFlight = 0; + const setStyle = vi.fn(async () => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + inFlight -= 1; + return { ok: true } as const; + }); + + await studioSetStyle(contentDeps({ setStyle }), { + styles: { color: "red", "font-size": "48px", opacity: "0.5" }, + }); + + expect(maxInFlight).toBe(1); + }); + + it("reports a partial success as partial, not whole", async () => { + const setStyle = vi.fn(async (property: string) => + property === "left" + ? ({ ok: false, reason: "geometry-property" } as const) + : ({ ok: true } as const), + ); + + const result = await studioSetStyle(contentDeps({ setStyle }), { + styles: { color: "red", left: "10px" }, + }); + + const ok = expectOk(result); + expect(ok.applied).toEqual({ color: "red" }); + expect(ok.rejected).toEqual({ left: "geometry-property" }); + }); + + it("fails when every property was refused", async () => { + const result = expectFailure( + await studioSetStyle( + contentDeps({ setStyle: async () => ({ ok: false, reason: "styles-not-editable" }) }), + { styles: { color: "red" } }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(result.reason).toMatch(/styles-not-editable/); + }); + + it("rejects an empty styles object rather than committing nothing", async () => { + const setStyle = vi.fn(); + + const result = expectFailure(await studioSetStyle(contentDeps({ setStyle }), { styles: {} })); + + expect(result.kind).toBe("invalid"); + expect(setStyle).not.toHaveBeenCalled(); + }); + + it("rejects a non-object styles value", async () => { + for (const styles of ["color: red", 42, null, ["color"]]) { + const result = expectFailure(await studioSetStyle(contentDeps(), { styles })); + expect(result.kind).toBe("invalid"); + } + }); + + it("refuses to write while a conflict is waiting for the user", async () => { + const setStyle = vi.fn(); + + const result = expectFailure( + await studioSetStyle( + contentDeps({ getWriteBlockedReason: () => "Auto-save is paused", setStyle }), + { styles: { color: "red" } }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(setStyle).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/webmcp/tools/contentTools.ts b/packages/studio/src/webmcp/tools/contentTools.ts new file mode 100644 index 0000000000..6e5a85b943 --- /dev/null +++ b/packages/studio/src/webmcp/tools/contentTools.ts @@ -0,0 +1,184 @@ +/** + * `studio_set_text` and `studio_set_style`: the first tools that change the file. + * + * Both operate on the CURRENT selection and take no handle. That is not an + * omission. `handleDomTextCommit(value, fieldKey?)` and + * `handleDomStyleCommit(property, value)` read the ambient React selection, and + * `applyDomSelection` only schedules a state update, so selecting and + * committing inside one call would write to whatever was selected before. + * Two tool calls are separated by a render. Select first, then edit. + * + * Every write here is guarded before dispatch and verified after. Studio has + * several paths where a failed commit resolves anyway, so "the function did not + * throw" proves nothing; the outcome the handler now returns is what proves it. + */ + +import type { DomEditCommitOutcome } from "../../hooks/domEditCommitRunner"; +import type { DomEditSelection } from "../../components/editor/domEditingTypes"; +import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult"; + +export interface ContentToolDeps { + getCurrentSelection: () => DomEditSelection | null; + /** Why a write would be refused right now, or null. Checked BEFORE dispatch. */ + getWriteBlockedReason: () => string | null; + setText: (value: string, fieldKey?: string) => Promise; + setStyle: (property: string, value: string) => Promise; +} + +/** + * The reasons a commit declines, translated into something an agent can act on. + * `persist-failed` is exogenous; the rest are states it should route around. + */ +const DECLINE_HINTS: Record = { + "no-selection": { kind: "invalid", hint: "Call studio_select first." }, + "no-project": { kind: "blocked" }, + "geometry-property": { + kind: "blocked", + hint: "Position and size are not editable as styles. Use the transform tools.", + }, + "styles-not-editable": { + kind: "blocked", + hint: "studio_inspect reports why, in can.reasonIfDisabled.", + }, + "not-text-editable": { + kind: "blocked", + hint: "This element has no editable text. studio_inspect lists its textFields.", + }, + "persist-failed": { kind: "failed", hint: "The write did not reach the file. Check Studio." }, +}; + +function fromOutcome(outcome: DomEditCommitOutcome, what: string): ToolFailure | null { + if (outcome.ok) return null; + const mapped = DECLINE_HINTS[outcome.reason] ?? { kind: "failed" as const }; + return toolFailure(mapped.kind, `${what} was not applied: ${outcome.reason}`, mapped.hint); +} + +function guardWrite(deps: ContentToolDeps): ToolFailure | null { + // Both blocked states are banners in Studio's UI with no lock behind them, so + // nothing else stops a programmatic write from landing on top of a conflict + // the user has been asked to adjudicate. + const blocked = deps.getWriteBlockedReason(); + if (blocked) { + return toolFailure("blocked", blocked, "Resolve it in Studio, then retry."); + } + if (!deps.getCurrentSelection()) { + return toolFailure("invalid", "nothing is selected", "Call studio_select first."); + } + return null; +} + +export interface StudioSetTextResult { + text: string; + changed: boolean; +} + +export async function studioSetText( + deps: ContentToolDeps, + input: { text?: unknown; field?: unknown }, +): Promise> { + if (typeof input.text !== "string") { + return toolFailure("invalid", "text must be a string"); + } + const field = typeof input.field === "string" && input.field ? input.field : undefined; + + const blocked = guardWrite(deps); + if (blocked) return blocked; + + const before = deps.getCurrentSelection()?.textContent ?? null; + const outcome = await deps.setText(input.text, field); + const failure = fromOutcome(outcome, "the text"); + if (failure) return failure; + + return toolOk({ text: input.text, changed: before !== input.text }); +} + +export interface StudioSetStyleResult { + applied: Record; + /** Properties the element refused, with the reason. Empty when all landed. */ + rejected: Record; +} + +export async function studioSetStyle( + deps: ContentToolDeps, + input: { styles?: unknown }, +): Promise> { + const styles = input.styles; + if (typeof styles !== "object" || styles === null || Array.isArray(styles)) { + return toolFailure("invalid", "styles must be an object of CSS property to value"); + } + const entries = Object.entries(styles).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + if (entries.length === 0) { + // An empty commit would report success having done nothing. + return toolFailure("invalid", "styles must contain at least one string value"); + } + + const blocked = guardWrite(deps); + if (blocked) return blocked; + + // `handleDomStyleCommit` is one property per call, so N properties are N + // commits and N undo entries. Sequential, not concurrent: two commits racing + // through Studio's client-side read-modify-write can record undo entries that + // both claim the same starting content. + const applied: Record = {}; + const rejected: Record = {}; + for (const [property, value] of entries) { + const outcome = await deps.setStyle(property, value); + if (outcome.ok) applied[property] = value; + else rejected[property] = outcome.reason; + } + + if (Object.keys(applied).length === 0) { + const reasons = Object.entries(rejected) + .map(([property, reason]) => `${property}: ${reason}`) + .join(", "); + return toolFailure("blocked", `no style was applied (${reasons})`); + } + + return toolOk({ applied, rejected }); +} + +export const STUDIO_SET_TEXT_INPUT_SCHEMA = { + type: "object", + properties: { + text: { type: "string", description: "The new text content." }, + field: { + type: "string", + description: + "Which text field to write, from studio_inspect. Omit for the element's own text.", + }, + }, + required: ["text"], + additionalProperties: false, +} as const; + +export const STUDIO_SET_TEXT_DESCRIPTION = [ + "Set the text of the CURRENTLY SELECTED element. Call studio_select first.", + "This is the edit a synthetic double-click cannot reach, because Studio's canvas", + "takes pointer capture and recognises the double press itself.", + "Returns `ok: true` with the resulting text and whether it changed, or `ok: false`", + "with `kind`, `reason` and usually a `hint` naming what to do instead.", +].join(" "); + +export const STUDIO_SET_STYLE_INPUT_SCHEMA = { + type: "object", + properties: { + styles: { + type: "object", + description: 'CSS property to value, for example {"color": "red", "font-size": "48px"}.', + additionalProperties: { type: "string" }, + }, + }, + required: ["styles"], + additionalProperties: false, +} as const; + +export const STUDIO_SET_STYLE_DESCRIPTION = [ + "Set inline styles on the CURRENTLY SELECTED element. Call studio_select first.", + "Each property is a separate commit, so N properties produce N undo entries.", + "Position and size properties (left, top, width, height) are refused here on purpose;", + "they belong to the transform tools.", + "Returns `ok: true` with `applied` and `rejected` maps, so a partial success is visible", + "as a partial success rather than reported as a whole one.", +].join(" "); diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index 3a9a3ee988..8431d84265 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -43,6 +43,9 @@ function deps(overrides: Partial = {}): StudioAgentToolsDe probeFrame: async () => ({ ok: true, status: 200 }), wait: async () => undefined, getCurrentSelection: () => null, + getWriteBlockedReason: () => null, + setText: async () => ({ ok: true }), + setStyle: async () => ({ ok: true }), getGsapDiagnostics: () => ({ animations: [], multipleTimelines: false, @@ -114,6 +117,8 @@ describe("useStudioAgentTools", () => { "studio_seek", "studio_frame", "studio_inspect", + "studio_set_text", + "studio_set_style", ]); expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present"); }); @@ -128,14 +133,14 @@ describe("useStudioAgentTools", () => { await act(async () => { harness = mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(5); + expect(registerTool).toHaveBeenCalledTimes(7); await act(async () => { harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) })); harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) })); }); - expect(registerTool).toHaveBeenCalledTimes(5); + expect(registerTool).toHaveBeenCalledTimes(7); }); it("executes against the LATEST deps, not the ones present at registration", async () => { @@ -208,7 +213,7 @@ describe("useStudioAgentTools", () => { mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(5); + expect(registerTool).toHaveBeenCalledTimes(7); }); it("reports a non-abort registration failure through production telemetry", async () => { diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index c5e1a444eb..a8f25c61c8 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -41,6 +41,17 @@ import { type StudioInspectInput, type StudioInspectResult, } from "./tools/inspectTools"; +import { + studioSetStyle, + studioSetText, + STUDIO_SET_STYLE_DESCRIPTION, + STUDIO_SET_STYLE_INPUT_SCHEMA, + STUDIO_SET_TEXT_DESCRIPTION, + STUDIO_SET_TEXT_INPUT_SCHEMA, + type ContentToolDeps, + type StudioSetStyleResult, + type StudioSetTextResult, +} from "./tools/contentTools"; const log = makeStudioDebugLogger("webmcp"); @@ -54,7 +65,8 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo } } -export interface StudioAgentToolsDeps extends SelectionToolDeps, FrameToolDeps, InspectToolDeps { +export interface StudioAgentToolsDeps + extends SelectionToolDeps, FrameToolDeps, InspectToolDeps, ContentToolDeps { /** Read Studio's current state. Called per tool invocation, never cached. */ getSnapshot: () => StudioLookSnapshot; } @@ -126,6 +138,24 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): studioInspect(depsRef.current, input as StudioInspectInput), ), }, + { + name: "studio_set_text", + title: "Set an element's text", + description: STUDIO_SET_TEXT_DESCRIPTION, + inputSchema: STUDIO_SET_TEXT_INPUT_SCHEMA, + annotations: { readOnlyHint: false, untrustedContentHint: true }, + execute: (input): Promise> => + runToolBody("studio_set_text", () => studioSetText(depsRef.current, input)), + }, + { + name: "studio_set_style", + title: "Set an element's styles", + description: STUDIO_SET_STYLE_DESCRIPTION, + inputSchema: STUDIO_SET_STYLE_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_set_style", () => studioSetStyle(depsRef.current, input)), + }, ]; }