The user asked to see what the agent is working on through MCP. Until now the agent had to call open for that, and mostly did not. - After run_commands the editor shows what the call worked on last. A graph opens its tab with the touched nodes selected, deleted ones left out. A scene or container shows with its objects selected. Any other asset shows in the inspector. - Objects in another scene switch the scene inside the same undo step. A switch of its own would be its own step, and Ctrl+Z would take back the switch before the agent's edit. - A graph tab that is open already moves its camera after the nodes. It stays put while they are in view, moves only as far as needed with the zoom kept, and frames them like Fit only when they do not fit. This is revealBoxes in framing.ts and the state's reveal event. - A tab that has just opened frames itself first and moves after that. - The account menu has "Follow agent" below "Agent", on by default and kept in the browser. While the game plays the editor never follows. - The run_commands description and the relay's instructions tell the agent about it. Tests: - agent-edit.test.ts: 3 new tests, each red with its bent-back fix (scene switch outside the undo step, deleted nodes selected, switch ignored) - graph-framing.test.ts 29/29, 6 of them new - e2e 71 on melektaus-atx: ALL PASSED, 20 checks. 71g4 adds a node 3000 px to the right of an open tab: it ends 24 px inside the edge, zoom still 1 - Tried live on a test project through a real MCP session: the tab opened, the nodes and the object were selected. With the old build a far node stayed out of view, after the reload the camera went there - Gate on melektaus-atx: CHECK EXIT 0, 318 files, 4931 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
7.3 KiB
TypeScript
193 lines
7.3 KiB
TypeScript
import type { Application } from 'pixi.js';
|
||
import { findAsset, findGraphAsset, findObjectHolder } from '../../core/model/Project';
|
||
import { Dialog } from '../../ui';
|
||
import { activateAsset, deleteQuestion } from '../actions';
|
||
import { measuredNodes } from '../graph/measureNodes';
|
||
import { type EditorState, nodeSelection, objectSelection } from '../state';
|
||
import type { AgentSelection, PlayAction, ToolHost } from './agentTools';
|
||
import type { AgentView } from './agentView';
|
||
import type { StepTarget } from './commandCatalog';
|
||
import { screenshot } from './screenshot';
|
||
|
||
/** What the agent's tools reach of the editor beyond its state. */
|
||
export interface EditorParts {
|
||
state: EditorState;
|
||
app: Application;
|
||
view(): AgentView;
|
||
showTab(tab: 'scene' | 'game'): void;
|
||
play: { readonly isPlaying: boolean; play(paused: boolean): void; stop(): void; togglePause(): void };
|
||
/** The user's "Follow agent" switch. */
|
||
follows(): boolean;
|
||
}
|
||
|
||
/** How long the user has to answer an agent's question before it counts as a no. */
|
||
export const CONFIRM_MS = 110_000;
|
||
|
||
/**
|
||
* The editor as the agent's tools see it (#429). `wait` tells the relay that an answer will take a
|
||
* while – the user is being asked something.
|
||
*/
|
||
export function editorToolHost(parts: EditorParts, wait: () => void): ToolHost {
|
||
const { state } = parts;
|
||
return {
|
||
view: () => parts.view(),
|
||
screenshot: (maxWidth) => screenshot(parts.app, maxWidth),
|
||
project: () => state.project,
|
||
execute: (command) => state.attempt(command),
|
||
confirmDeletes: (assetIds) => confirmDeletes(state, assetIds, wait),
|
||
open: ({ asset, tab }) => {
|
||
if (asset !== undefined) {
|
||
if (!findAsset(state.project, asset)) return `There is no asset ${asset}`;
|
||
activateAsset(state, asset);
|
||
return null;
|
||
}
|
||
if (tab === 'scene' || tab === 'game') {
|
||
parts.showTab(tab);
|
||
return null;
|
||
}
|
||
return 'Name an asset or the tab (scene or game)';
|
||
},
|
||
select: (selection) => select(state, selection),
|
||
play: (action) => play(parts, action),
|
||
undo: () => {
|
||
const label = state.commands.undoLabel;
|
||
if (label !== null) state.undo();
|
||
return label;
|
||
},
|
||
redo: () => {
|
||
const label = state.commands.redoLabel;
|
||
if (label !== null) state.redo();
|
||
return label;
|
||
},
|
||
measure: (assetId) => measuredNodes(state, assetId),
|
||
// A running game keeps the user's eyes: an edit then goes to the running copy anyway.
|
||
follows: () => parts.follows() && !parts.play.isPlaying,
|
||
show: (focus) => show(parts, focus),
|
||
};
|
||
}
|
||
|
||
/** Brings up what an edit worked on: a graph's tab, the scene or container, the asset in the inspector. */
|
||
function show(parts: EditorParts, focus: StepTarget): void {
|
||
const { state } = parts;
|
||
if (focus.kind === 'asset') {
|
||
state.select({ kind: 'asset', assetId: focus.asset });
|
||
return;
|
||
}
|
||
const holder = focus.kind === 'objects' ? focus.in : focus.asset;
|
||
if (focus.ids.length === 0) {
|
||
state.openAsset(holder);
|
||
return;
|
||
}
|
||
if (focus.kind === 'nodes') {
|
||
state.select(nodeSelection(holder, focus.ids, focus.ids[0], [], []));
|
||
// A tab that was open already keeps its camera; what was added off to one side comes into it.
|
||
state.revealNodes(holder, focus.ids);
|
||
return;
|
||
}
|
||
if (findAsset(state.project, holder)?.type === 'scene') parts.showTab('scene');
|
||
state.select(objectSelection(holder, focus.ids));
|
||
}
|
||
|
||
function select(state: EditorState, selection: AgentSelection): string | null {
|
||
switch (selection.kind) {
|
||
case 'none':
|
||
state.select(null);
|
||
return null;
|
||
case 'asset':
|
||
if (!selection.asset || !findAsset(state.project, selection.asset))
|
||
return `There is no asset ${selection.asset}`;
|
||
state.select({ kind: 'asset', assetId: selection.asset });
|
||
return null;
|
||
case 'objects': {
|
||
const holder = selection.in ? findObjectHolder(state.project, selection.in) : undefined;
|
||
if (!holder) return `There is no scene or container ${selection.in}`;
|
||
const ids = selection.objects ?? [];
|
||
const unknown = ids.find((id) => !holder.objects.some((object) => object.id === id));
|
||
if (ids.length === 0 || unknown !== undefined)
|
||
return `Name objects of ${holder.name}${unknown ? `; ${unknown} is none` : ''}`;
|
||
state.select(objectSelection(holder.id, ids));
|
||
return null;
|
||
}
|
||
case 'nodes': {
|
||
const holder = selection.asset ? findGraphAsset(state.project, selection.asset) : undefined;
|
||
if (!holder) return `There is no graph asset ${selection.asset}`;
|
||
const ids = selection.nodes ?? [];
|
||
const unknown = ids.find((id) => !holder.graph.nodes.some((node) => node.id === id));
|
||
if (ids.length === 0 || unknown !== undefined)
|
||
return `Name nodes of ${holder.name}${unknown ? `; ${unknown} is none` : ''}`;
|
||
state.select(nodeSelection(holder.id, ids, ids[0], [], []));
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function play(parts: EditorParts, action: PlayAction): string | null {
|
||
const { state, play: session } = parts;
|
||
switch (action) {
|
||
case 'play':
|
||
if (session.isPlaying) return 'The game is running already';
|
||
if (!state.sceneToPlay()) return 'There is no scene to play';
|
||
session.play(false);
|
||
// `play` waits for the physics engine when it is not loaded yet, and starts nothing then.
|
||
return isRunning(session)
|
||
? null
|
||
: 'The game could not start yet (the physics engine is still loading); try again';
|
||
case 'stop':
|
||
if (!session.isPlaying) return 'The game is not running';
|
||
session.stop();
|
||
return null;
|
||
case 'pause':
|
||
case 'resume':
|
||
if (!session.isPlaying) return 'The game is not running';
|
||
if (state.paused === (action === 'pause'))
|
||
return `The game is ${state.paused ? 'paused' : 'running'} already`;
|
||
session.togglePause();
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function isRunning(session: EditorParts['play']): boolean {
|
||
return session.isPlaying;
|
||
}
|
||
|
||
/** One question for all the assets an edit would delete; the user's no, a refusal or the time running out is the answer. */
|
||
function confirmDeletes(
|
||
state: EditorState,
|
||
assetIds: readonly string[],
|
||
wait: () => void,
|
||
): Promise<string | null> {
|
||
const questions = [];
|
||
for (const id of assetIds) {
|
||
const question = deleteQuestion(state.project, id);
|
||
if (!question) return Promise.resolve(`Nothing was changed. There is no asset ${id}`);
|
||
if ('refusal' in question) return Promise.resolve(`Nothing was changed. ${question.refusal}`);
|
||
questions.push(question);
|
||
}
|
||
const title =
|
||
questions.length === 1
|
||
? `The agent asks: ${questions[0]!.title}`
|
||
: `The agent asks: delete ${questions.length} assets?`;
|
||
const detail = questions
|
||
.map((q) => (questions.length === 1 ? q.detail : `${q.title} ${q.detail}`))
|
||
.join('\n');
|
||
wait();
|
||
return new Promise((resolve) => {
|
||
let answered = false;
|
||
const answer = (refusal: string | null) => {
|
||
if (answered) return;
|
||
answered = true;
|
||
clearTimeout(timer);
|
||
resolve(refusal);
|
||
};
|
||
const dialog = new Dialog(title, detail, [
|
||
{ label: 'Cancel', onClick: () => answer('The user said no; nothing was changed.') },
|
||
{ label: 'Delete', danger: true, onClick: () => answer(null) },
|
||
]);
|
||
const timer = setTimeout(() => {
|
||
answer(`The user did not answer within ${CONFIRM_MS / 1000} s; nothing was changed.`);
|
||
dialog.close();
|
||
}, CONFIRM_MS);
|
||
dialog.openCentered();
|
||
});
|
||
}
|