diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx index f201ca23bf..bdfa2cba31 100644 --- a/packages/studio/src/webmcp/StudioAgentTools.tsx +++ b/packages/studio/src/webmcp/StudioAgentTools.tsx @@ -33,6 +33,10 @@ export function StudioAgentTools() { handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, + handleGsapAddAnimation, + handleGsapUpdateMeta, + handleGsapAddKeyframeBatch, + handleGsapDeleteAnimation, } = useDomEditActionsContext(); const getSnapshot = useCallback((): StudioLookSnapshot => { @@ -97,6 +101,11 @@ export function StudioAgentTools() { moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next), resizeTo: (selection, next) => handleDomBoxSizeCommit(selection, next), rotateTo: (selection, next) => handleDomRotationCommit(selection, next), + addAnimation: (method) => handleGsapAddAnimation(method), + updateAnimation: (animationId, updates) => handleGsapUpdateMeta(animationId, updates), + addKeyframe: (animationId, percent, properties) => + handleGsapAddKeyframeBatch(animationId, percent, properties), + deleteAnimation: (animationId) => handleGsapDeleteAnimation(animationId), getGsapDiagnostics: () => ({ animations: selectedGsapAnimations, multipleTimelines: gsapMultipleTimelines, @@ -116,6 +125,10 @@ export function StudioAgentTools() { handleDomPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, + handleGsapAddAnimation, + handleGsapUpdateMeta, + handleGsapAddKeyframeBatch, + handleGsapDeleteAnimation, domEditSelection, selectedGsapAnimations, gsapMultipleTimelines, diff --git a/packages/studio/src/webmcp/tools/animationTools.test.ts b/packages/studio/src/webmcp/tools/animationTools.test.ts new file mode 100644 index 0000000000..f8f60bb8d5 --- /dev/null +++ b/packages/studio/src/webmcp/tools/animationTools.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import { + studioAddAnimation, + studioAddKeyframe, + studioDeleteAnimation, + studioUpdateAnimation, + type AnimationToolDeps, + type StudioAddAnimationResult, + type StudioAddKeyframeResult, + type StudioUpdateAnimationResult, +} from "./animationTools"; +import { expectFailure, expectOk, previewElement, selectionFor } from "../webmcpTestUtils"; + +function animationDeps(overrides: Partial = {}): AnimationToolDeps { + const element = previewElement('

Ship it

', "headline"); + return { + getCurrentSelection: () => selectionFor(element), + getWriteBlockedReason: () => null, + readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }), + addAnimation: () => undefined, + updateAnimation: async () => true, + addKeyframe: async () => undefined, + deleteAnimation: () => undefined, + ...overrides, + }; +} + +describe("studioAddAnimation", () => { + it("reports where the playhead actually was, not a position the caller chose", async () => { + // The handler reads the playhead itself and ignores any position argument, + // so echoing one back would report a number that had no effect. + const addAnimation = vi.fn(); + + const result = await studioAddAnimation( + animationDeps({ + addAnimation, + readPlayhead: () => ({ currentTime: 7.25, duration: 10, isPlaying: false }), + }), + { method: "from" }, + ); + + const ok = expectOk(result); + expect(ok.insertedAtSeconds).toBe(7.25); + expect(ok.method).toBe("from"); + expect(addAnimation).toHaveBeenCalledWith("from"); + }); + + it("marks the result as dispatched rather than claiming it landed", async () => { + // `handleGsapAddAnimation` is fire-and-forget and returns nothing, so there + // is no honest success signal to report. + const result = await studioAddAnimation(animationDeps(), { method: "to" }); + + expect(expectOk(result).dispatched).toBe(true); + }); + + it("rejects an unknown method without dispatching", async () => { + const addAnimation = vi.fn(); + + const result = expectFailure( + await studioAddAnimation(animationDeps({ addAnimation }), { method: "wiggle" }), + ); + + expect(result.kind).toBe("invalid"); + expect(addAnimation).not.toHaveBeenCalled(); + }); + + it("refuses while a write is blocked, and when nothing is selected", async () => { + const addAnimation = vi.fn(); + + const paused = expectFailure( + await studioAddAnimation( + animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", addAnimation }), + { method: "to" }, + ), + ); + const unselected = expectFailure( + await studioAddAnimation(animationDeps({ getCurrentSelection: () => null, addAnimation }), { + method: "to", + }), + ); + + expect(paused.kind).toBe("blocked"); + expect(unselected.kind).toBe("invalid"); + expect(addAnimation).not.toHaveBeenCalled(); + }); +}); + +describe("studioUpdateAnimation", () => { + it("confirms the write, because this handler actually reports back", async () => { + const updateAnimation = vi.fn(async () => true); + + const result = await studioUpdateAnimation(animationDeps({ updateAnimation }), { + animationId: "anim-1", + ease: "power2.out", + duration: 1.5, + }); + + const ok = expectOk(result); + expect(ok.updated).toEqual({ duration: 1.5, ease: "power2.out" }); + expect(updateAnimation).toHaveBeenCalledWith("anim-1", { + duration: 1.5, + ease: "power2.out", + }); + }); + + it("reports a false return as a real failure", async () => { + const result = expectFailure( + await studioUpdateAnimation(animationDeps({ updateAnimation: async () => false }), { + animationId: "anim-gone", + ease: "none", + }), + ); + + expect(result.kind).toBe("failed"); + expect(result.hint).toMatch(/stale/); + }); + + it("rules out the no-selection case BEFORE dispatch, so a false is unambiguous", async () => { + // The handler answers `false` for both "nothing selected" and "the write + // failed". Eliminating one beforehand is what makes the other legible. + const updateAnimation = vi.fn(async () => false); + + const result = expectFailure( + await studioUpdateAnimation( + animationDeps({ getCurrentSelection: () => null, updateAnimation }), + { animationId: "anim-1", ease: "none" }, + ), + ); + + expect(result.kind).toBe("invalid"); + expect(result.reason).toMatch(/nothing is selected/); + expect(updateAnimation).not.toHaveBeenCalled(); + }); + + it("requires at least one field, and rejects a negative duration", async () => { + const deps = animationDeps(); + + expect(expectFailure(await studioUpdateAnimation(deps, { animationId: "a" })).reason).toMatch( + /at least one/, + ); + expect( + expectFailure(await studioUpdateAnimation(deps, { animationId: "a", duration: -1 })).reason, + ).toMatch(/negative/); + }); + + it("rejects a blank animation id", async () => { + const updateAnimation = vi.fn(); + + const result = expectFailure( + await studioUpdateAnimation(animationDeps({ updateAnimation }), { + animationId: " ", + ease: "none", + }), + ); + + expect(result.kind).toBe("invalid"); + expect(updateAnimation).not.toHaveBeenCalled(); + }); +}); + +describe("studioAddKeyframe", () => { + it("passes every property through in one commit", async () => { + const addKeyframe = vi.fn(async () => undefined); + + const result = await studioAddKeyframe(animationDeps({ addKeyframe }), { + animationId: "anim-1", + percent: 50, + properties: { y: -50, opacity: 0 }, + }); + + const ok = expectOk(result); + expect(ok.properties).toEqual({ y: -50, opacity: 0 }); + // One call, so one undo entry, rather than one per property. + expect(addKeyframe).toHaveBeenCalledTimes(1); + expect(addKeyframe).toHaveBeenCalledWith("anim-1", 50, { y: -50, opacity: 0 }); + }); + + it("validates percent itself, because the platform does not", async () => { + // Nothing checks the input object against inputSchema, so the tool receives + // whatever the agent sent. + const addKeyframe = vi.fn(); + const deps = animationDeps({ addKeyframe }); + + for (const percent of [-1, 101, Number.NaN, "50"]) { + const result = expectFailure( + await studioAddKeyframe(deps, { animationId: "a", percent, properties: { y: 1 } }), + ); + expect(result.kind).toBe("invalid"); + } + expect(addKeyframe).not.toHaveBeenCalled(); + }); + + it("rejects properties that carry no usable value", async () => { + const addKeyframe = vi.fn(); + const deps = animationDeps({ addKeyframe }); + + for (const properties of [{}, { y: null }, [], "y:1"]) { + const result = expectFailure( + await studioAddKeyframe(deps, { animationId: "a", percent: 50, properties }), + ); + expect(result.kind).toBe("invalid"); + } + expect(addKeyframe).not.toHaveBeenCalled(); + }); + + it("accepts 0 and 100 as the ends of the tween", async () => { + for (const percent of [0, 100]) { + const result = await studioAddKeyframe(animationDeps(), { + animationId: "a", + percent, + properties: { y: 1 }, + }); + expect(expectOk(result).percent).toBe(percent); + } + }); +}); + +describe("studioDeleteAnimation", () => { + it("dispatches the delete and says so", async () => { + const deleteAnimation = vi.fn(); + + const result = await studioDeleteAnimation(animationDeps({ deleteAnimation }), { + animationId: "anim-1", + }); + + expect(result.ok).toBe(true); + expect(deleteAnimation).toHaveBeenCalledWith("anim-1"); + }); + + it("refuses while a write is blocked", async () => { + const deleteAnimation = vi.fn(); + + const result = expectFailure( + await studioDeleteAnimation( + animationDeps({ getWriteBlockedReason: () => "Auto-save is paused", deleteAnimation }), + { animationId: "anim-1" }, + ), + ); + + expect(result.kind).toBe("blocked"); + expect(deleteAnimation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/webmcp/tools/animationTools.ts b/packages/studio/src/webmcp/tools/animationTools.ts new file mode 100644 index 0000000000..0d5d678bc4 --- /dev/null +++ b/packages/studio/src/webmcp/tools/animationTools.ts @@ -0,0 +1,276 @@ +/** + * `studio_animate`: author motion. + * + * These tools are deliberately less confident than the rest, because the + * handlers underneath them are: + * + * - `handleGsapAddAnimation(method)` takes ONLY a method. Its insert position + * comes from the live playhead, not from the caller, and the call is + * `void ...catch()`, so it returns nothing and cannot be awaited. + * - `handleGsapAddKeyframeBatch` returns a promise but catches its own failure, + * so awaiting it proves the call finished, not that it landed. + * - `handleGsapDeleteAnimation` discards its promise entirely. + * - `handleGsapUpdateMeta` is the one honest signal: it returns a boolean. + * Its `false` is ambiguous though, meaning either no selection or a failed + * write, so the no-selection case is ruled out before dispatch. + * + * U8 solved the same problem by reading the result back. That does not work + * here: the animation list comes from React state that only refreshes on a + * render, and no render happens inside one tool call. So rather than fake a + * verification, these report what was dispatched and tell the agent to call + * `studio_inspect` to see the result. Saying "I asked for this" is honest; + * saying "this happened" would not be. + */ + +import type { DomEditSelection } from "../../components/editor/domEditingTypes"; +import { toolFailure, toolOk, type ToolFailure, type ToolResult } from "../toolResult"; + +export type GsapMethod = "to" | "from" | "set" | "fromTo"; + +const METHODS: readonly GsapMethod[] = ["to", "from", "set", "fromTo"]; + +export interface AnimationToolDeps { + getCurrentSelection: () => DomEditSelection | null; + getWriteBlockedReason: () => string | null; + readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean }; + addAnimation: (method: GsapMethod) => void; + updateAnimation: ( + animationId: string, + updates: { duration?: number; ease?: string; position?: number }, + ) => Promise; + addKeyframe: ( + animationId: string, + percent: number, + properties: Record, + ) => Promise; + deleteAnimation: (animationId: string) => void; +} + +const INSPECT_HINT = "Call studio_inspect to see the result."; + +function guard(deps: AnimationToolDeps): ToolFailure | null { + 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; +} + +function readAnimationId(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +export interface StudioAddAnimationResult { + method: GsapMethod; + /** Where it was inserted, which is the playhead, not a value you supplied. */ + insertedAtSeconds: number; + dispatched: true; +} + +export async function studioAddAnimation( + deps: AnimationToolDeps, + input: { method?: unknown }, +): Promise> { + const method = METHODS.find((candidate) => candidate === input.method); + if (!method) { + return toolFailure("invalid", `method must be one of ${METHODS.join(", ")}`); + } + + const blocked = guard(deps); + if (blocked) return blocked; + + // The handler reads the playhead itself. Reporting a position the caller gave + // us would be reporting a number that had no effect, so the tool takes no + // position and reports where the playhead actually is instead. + const { currentTime } = deps.readPlayhead(); + deps.addAnimation(method); + + return toolOk({ + method, + insertedAtSeconds: currentTime, + dispatched: true, + }); +} + +export interface StudioUpdateAnimationResult { + animationId: string; + updated: { duration?: number; ease?: string; position?: number }; +} + +export async function studioUpdateAnimation( + deps: AnimationToolDeps, + input: { animationId?: unknown; duration?: unknown; ease?: unknown; position?: unknown }, +): Promise> { + const animationId = readAnimationId(input.animationId); + if (!animationId) { + return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT); + } + + const updates: { duration?: number; ease?: string; position?: number } = {}; + if (typeof input.duration === "number" && Number.isFinite(input.duration)) { + if (input.duration < 0) return toolFailure("invalid", "duration must not be negative"); + updates.duration = input.duration; + } + if (typeof input.ease === "string" && input.ease.trim()) updates.ease = input.ease; + if (typeof input.position === "number" && Number.isFinite(input.position)) { + updates.position = input.position; + } + if (Object.keys(updates).length === 0) { + return toolFailure("invalid", "give at least one of duration, ease, position"); + } + + // Ruled out BEFORE dispatch on purpose: the handler answers `false` for both + // "nothing selected" and "the write failed", so a false afterwards would be + // ambiguous. Eliminating one of the two makes the other one legible. + const blocked = guard(deps); + if (blocked) return blocked; + + const landed = await deps.updateAnimation(animationId, updates); + if (!landed) { + return toolFailure( + "failed", + `the update to ${animationId} did not land`, + "The animation id may be stale. studio_inspect lists the current ones.", + ); + } + + return toolOk({ animationId, updated: updates }); +} + +export interface StudioAddKeyframeResult { + animationId: string; + percent: number; + properties: Record; + dispatched: true; +} + +export async function studioAddKeyframe( + deps: AnimationToolDeps, + input: { animationId?: unknown; percent?: unknown; properties?: unknown }, +): Promise> { + const animationId = readAnimationId(input.animationId); + if (!animationId) { + return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT); + } + const percent = input.percent; + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) { + // Validated here because nothing in the platform checks input against the + // schema; the tool receives whatever the agent sent. + return toolFailure("invalid", "percent must be a number between 0 and 100"); + } + const raw = input.properties; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return toolFailure("invalid", "properties must be an object of GSAP property to value"); + } + const properties: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value === "number" || typeof value === "string") properties[key] = value; + } + if (Object.keys(properties).length === 0) { + return toolFailure("invalid", "properties must contain at least one number or string value"); + } + + const blocked = guard(deps); + if (blocked) return blocked; + + await deps.addKeyframe(animationId, percent, properties); + + return toolOk({ animationId, percent, properties, dispatched: true }); +} + +export interface StudioDeleteAnimationResult { + animationId: string; + dispatched: true; +} + +export async function studioDeleteAnimation( + deps: AnimationToolDeps, + input: { animationId?: unknown }, +): Promise> { + const animationId = readAnimationId(input.animationId); + if (!animationId) { + return toolFailure("invalid", "animationId must be a non-empty string", INSPECT_HINT); + } + + const blocked = guard(deps); + if (blocked) return blocked; + + deps.deleteAnimation(animationId); + return toolOk({ animationId, dispatched: true }); +} + +const DISPATCH_CAVEAT = `Reports what was dispatched, not what landed: the handler underneath does not report back. ${INSPECT_HINT}`; + +export const STUDIO_ADD_ANIMATION_INPUT_SCHEMA = { + type: "object", + properties: { + method: { type: "string", enum: METHODS, description: "The GSAP method to add." }, + }, + required: ["method"], + additionalProperties: false, +} as const; + +export const STUDIO_ADD_ANIMATION_DESCRIPTION = [ + "Add a GSAP animation to the CURRENTLY SELECTED element. Call studio_select first.", + "It is inserted AT THE PLAYHEAD, which this tool does not control: call studio_seek first", + "to choose when it starts. The result reports where the playhead actually was.", + DISPATCH_CAVEAT, +].join(" "); + +export const STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA = { + type: "object", + properties: { + animationId: { type: "string", description: "An animation id from studio_inspect." }, + duration: { type: "number", minimum: 0, description: "Duration in seconds." }, + ease: { type: "string", description: "A GSAP ease, for example power2.out." }, + position: { type: "number", description: "Start position in seconds." }, + }, + required: ["animationId"], + additionalProperties: false, +} as const; + +export const STUDIO_UPDATE_ANIMATION_DESCRIPTION = [ + "Change an existing animation's duration, ease or position.", + "This is the one animation tool that CONFIRMS its write, so a failure here is real", + "and usually means a stale animationId. Get current ids from studio_inspect.", +].join(" "); + +export const STUDIO_ADD_KEYFRAME_INPUT_SCHEMA = { + type: "object", + properties: { + animationId: { type: "string", description: "An animation id from studio_inspect." }, + percent: { + type: "number", + minimum: 0, + maximum: 100, + description: "Where in the tween, 0 to 100.", + }, + properties: { + type: "object", + description: 'GSAP property to value, for example {"y": -50, "opacity": 0}.', + }, + }, + required: ["animationId", "percent", "properties"], + additionalProperties: false, +} as const; + +export const STUDIO_ADD_KEYFRAME_DESCRIPTION = [ + "Add a keyframe to an existing animation at a percentage through it.", + "All the properties land in one commit, so they are one undo entry.", + DISPATCH_CAVEAT, +].join(" "); + +export const STUDIO_DELETE_ANIMATION_INPUT_SCHEMA = { + type: "object", + properties: { + animationId: { type: "string", description: "An animation id from studio_inspect." }, + }, + required: ["animationId"], + additionalProperties: false, +} as const; + +export const STUDIO_DELETE_ANIMATION_DESCRIPTION = [ + "Remove an animation from the currently selected element. Undo reverses it.", + DISPATCH_CAVEAT, +].join(" "); diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index 274b697c91..b797cc0f87 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -50,6 +50,10 @@ function deps(overrides: Partial = {}): StudioAgentToolsDe moveTo: async () => undefined, resizeTo: async () => undefined, rotateTo: async () => undefined, + addAnimation: () => undefined, + updateAnimation: async () => true, + addKeyframe: async () => undefined, + deleteAnimation: () => undefined, getGsapDiagnostics: () => ({ animations: [], multipleTimelines: false, @@ -124,6 +128,10 @@ describe("useStudioAgentTools", () => { "studio_set_text", "studio_set_style", "studio_transform", + "studio_add_animation", + "studio_update_animation", + "studio_add_keyframe", + "studio_delete_animation", ]); expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present"); }); @@ -138,14 +146,14 @@ describe("useStudioAgentTools", () => { await act(async () => { harness = mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(8); + expect(registerTool).toHaveBeenCalledTimes(12); await act(async () => { harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) })); harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) })); }); - expect(registerTool).toHaveBeenCalledTimes(8); + expect(registerTool).toHaveBeenCalledTimes(12); }); it("executes against the LATEST deps, not the ones present at registration", async () => { @@ -218,7 +226,7 @@ describe("useStudioAgentTools", () => { mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(8); + expect(registerTool).toHaveBeenCalledTimes(12); }); 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 f62c369bcd..3402dc66c7 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -60,6 +60,25 @@ import { type StudioTransformResult, type TransformToolDeps, } from "./tools/transformTools"; +import { + studioAddAnimation, + studioAddKeyframe, + studioDeleteAnimation, + studioUpdateAnimation, + STUDIO_ADD_ANIMATION_DESCRIPTION, + STUDIO_ADD_ANIMATION_INPUT_SCHEMA, + STUDIO_ADD_KEYFRAME_DESCRIPTION, + STUDIO_ADD_KEYFRAME_INPUT_SCHEMA, + STUDIO_DELETE_ANIMATION_DESCRIPTION, + STUDIO_DELETE_ANIMATION_INPUT_SCHEMA, + STUDIO_UPDATE_ANIMATION_DESCRIPTION, + STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA, + type AnimationToolDeps, + type StudioAddAnimationResult, + type StudioAddKeyframeResult, + type StudioDeleteAnimationResult, + type StudioUpdateAnimationResult, +} from "./tools/animationTools"; const log = makeStudioDebugLogger("webmcp"); @@ -74,7 +93,13 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo } export interface StudioAgentToolsDeps - extends SelectionToolDeps, FrameToolDeps, InspectToolDeps, ContentToolDeps, TransformToolDeps { + extends + SelectionToolDeps, + FrameToolDeps, + InspectToolDeps, + ContentToolDeps, + TransformToolDeps, + AnimationToolDeps { /** Read Studio's current state. Called per tool invocation, never cached. */ getSnapshot: () => StudioLookSnapshot; } @@ -175,6 +200,42 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): studioTransform(depsRef.current, input as StudioTransformInput), ), }, + { + name: "studio_add_animation", + title: "Add an animation", + description: STUDIO_ADD_ANIMATION_DESCRIPTION, + inputSchema: STUDIO_ADD_ANIMATION_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_add_animation", () => studioAddAnimation(depsRef.current, input)), + }, + { + name: "studio_update_animation", + title: "Change an animation", + description: STUDIO_UPDATE_ANIMATION_DESCRIPTION, + inputSchema: STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_update_animation", () => studioUpdateAnimation(depsRef.current, input)), + }, + { + name: "studio_add_keyframe", + title: "Add a keyframe", + description: STUDIO_ADD_KEYFRAME_DESCRIPTION, + inputSchema: STUDIO_ADD_KEYFRAME_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_add_keyframe", () => studioAddKeyframe(depsRef.current, input)), + }, + { + name: "studio_delete_animation", + title: "Remove an animation", + description: STUDIO_DELETE_ANIMATION_DESCRIPTION, + inputSchema: STUDIO_DELETE_ANIMATION_INPUT_SCHEMA, + annotations: { readOnlyHint: false }, + execute: (input): Promise> => + runToolBody("studio_delete_animation", () => studioDeleteAnimation(depsRef.current, input)), + }, ]; }