pmx-canvas 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/Readme.md +14 -7
  3. package/dist/canvas/global.css +25 -0
  4. package/dist/canvas/index.js +72 -72
  5. package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/dist/types/client/icons.d.ts +4 -0
  9. package/dist/types/client/state/canvas-store.d.ts +16 -1
  10. package/dist/types/client/types.d.ts +20 -0
  11. package/dist/types/mcp/canvas-access.d.ts +1 -0
  12. package/dist/types/server/canvas-serialization.d.ts +25 -1
  13. package/dist/types/server/canvas-state.d.ts +27 -1
  14. package/dist/types/server/index.d.ts +7 -2
  15. package/dist/types/server/mutation-history.d.ts +1 -1
  16. package/dist/types/server/spatial-analysis.d.ts +11 -2
  17. package/package.json +1 -1
  18. package/skills/pmx-canvas/SKILL.md +19 -0
  19. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +145 -0
  20. package/src/cli/agent.ts +6 -0
  21. package/src/client/App.tsx +60 -3
  22. package/src/client/canvas/AnnotationLayer.tsx +28 -0
  23. package/src/client/canvas/CanvasViewport.tsx +169 -10
  24. package/src/client/canvas/ContextPinBar.tsx +2 -1
  25. package/src/client/canvas/use-pan-zoom.ts +10 -5
  26. package/src/client/icons.tsx +22 -0
  27. package/src/client/state/canvas-store.ts +52 -2
  28. package/src/client/state/sse-bridge.ts +35 -1
  29. package/src/client/theme/global.css +25 -0
  30. package/src/client/types.ts +17 -0
  31. package/src/mcp/canvas-access.ts +10 -0
  32. package/src/mcp/server.ts +43 -6
  33. package/src/server/canvas-schema.ts +25 -0
  34. package/src/server/canvas-serialization.ts +117 -1
  35. package/src/server/canvas-state.ts +74 -2
  36. package/src/server/diagram-presets.ts +54 -19
  37. package/src/server/index.ts +20 -3
  38. package/src/server/mutation-history.ts +2 -0
  39. package/src/server/server.ts +77 -2
  40. package/src/server/spatial-analysis.ts +46 -1
  41. package/src/shared/semantic-attention.ts +4 -2
@@ -1,5 +1,5 @@
1
1
  import { batch, computed, signal } from '@preact/signals';
2
- import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
2
+ import { isExcalidrawNode, type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
3
3
  import { computeAutoArrange } from '../../shared/auto-arrange';
4
4
  import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
5
5
 
@@ -11,6 +11,7 @@ function logCanvasStoreError(action: string, error: unknown): void {
11
11
  export const viewport = signal<ViewportState>({ x: 0, y: 0, scale: 1 });
12
12
  export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
13
13
  export const edges = signal<Map<string, CanvasEdge>>(new Map());
14
+ export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
14
15
  export const activeNodeId = signal<string | null>(null);
15
16
  export const connectionStatus = signal<ConnectionStatus>('connecting');
16
17
  export const sessionId = signal<string>('');
@@ -258,6 +259,50 @@ export function removeEdgesForNode(nodeId: string): void {
258
259
  if (changed) edges.value = next;
259
260
  }
260
261
 
262
+ export function addAnnotation(annotation: CanvasAnnotation): void {
263
+ const next = new Map(annotations.value);
264
+ next.set(annotation.id, annotation);
265
+ annotations.value = next;
266
+ }
267
+
268
+ export function removeAnnotation(id: string): void {
269
+ const next = new Map(annotations.value);
270
+ if (!next.delete(id)) return;
271
+ annotations.value = next;
272
+ }
273
+
274
+ export async function createAnnotationFromClient(input: {
275
+ points: CanvasAnnotation['points'];
276
+ color: string;
277
+ width: number;
278
+ label?: string;
279
+ }): Promise<{ ok: boolean }> {
280
+ try {
281
+ const res = await fetch('/api/canvas/annotation', {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify(input),
285
+ });
286
+ return { ok: res.ok };
287
+ } catch (error) {
288
+ logCanvasStoreError('createAnnotationFromClient', error);
289
+ return { ok: false };
290
+ }
291
+ }
292
+
293
+ export async function removeAnnotationFromClient(id: string): Promise<{ ok: boolean }> {
294
+ try {
295
+ const res = await fetch(`/api/canvas/annotation/${encodeURIComponent(id)}`, {
296
+ method: 'DELETE',
297
+ });
298
+ if (res.ok) removeAnnotation(id);
299
+ return { ok: res.ok };
300
+ } catch (error) {
301
+ logCanvasStoreError('removeAnnotationFromClient', error);
302
+ return { ok: false };
303
+ }
304
+ }
305
+
261
306
  export function resizeNode(id: string, size: { width: number; height: number }): void {
262
307
  const existing = nodes.value.get(id);
263
308
  if (!existing) return;
@@ -343,7 +388,7 @@ function commitViewportWithOptions(
343
388
  }
344
389
 
345
390
  export function applyServerCanvasLayout(
346
- layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
391
+ layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState; annotations?: CanvasAnnotation[] },
347
392
  options: { applyViewport?: boolean } = {},
348
393
  ): void {
349
394
  const nextNodes = new Map<string, CanvasNodeState>();
@@ -360,6 +405,10 @@ export function applyServerCanvasLayout(
360
405
  for (const edge of edgeSource) {
361
406
  nextEdges.set(edge.id, edge);
362
407
  }
408
+ const nextAnnotations = new Map<string, CanvasAnnotation>();
409
+ for (const annotation of layout.annotations ?? []) {
410
+ nextAnnotations.set(annotation.id, annotation);
411
+ }
363
412
 
364
413
  const nextActiveNodeId =
365
414
  activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
@@ -375,6 +424,7 @@ export function applyServerCanvasLayout(
375
424
  maxZ = nextMaxZ;
376
425
  nodes.value = nextNodes;
377
426
  edges.value = nextEdges;
427
+ annotations.value = nextAnnotations;
378
428
  activeNodeId.value = nextActiveNodeId;
379
429
  expandedNodeId.value = nextExpandedNodeId;
380
430
  if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
@@ -1,6 +1,6 @@
1
1
  import { findOpenCanvasPosition } from '../utils/placement.js';
2
2
  import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
3
- import type { CanvasEdge, CanvasNodeState } from '../types';
3
+ import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
4
4
  import {
5
5
  activeNodeId,
6
6
  addEdge,
@@ -310,6 +310,7 @@ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
310
310
  || value === 'trace'
311
311
  || value === 'file'
312
312
  || value === 'image'
313
+ || value === 'html'
313
314
  || value === 'group';
314
315
  }
315
316
 
@@ -334,6 +335,12 @@ function parseCanvasSize(value: unknown): { width: number; height: number } | nu
334
335
  return { width: size.width, height: size.height };
335
336
  }
336
337
 
338
+ function parseCanvasRect(value: unknown): { x: number; y: number; width: number; height: number } | null {
339
+ const position = parseCanvasPosition(value);
340
+ const size = parseCanvasSize(value);
341
+ return position && size ? { ...position, ...size } : null;
342
+ }
343
+
337
344
  function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
338
345
  if (typeof raw.id !== 'string' || !raw.id) return null;
339
346
  if (!isCanvasNodeType(raw.type)) return null;
@@ -379,6 +386,28 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
379
386
  };
380
387
  }
381
388
 
389
+ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
390
+ if (typeof raw.id !== 'string' || !raw.id) return null;
391
+ if (raw.type !== 'freehand') return null;
392
+ if (!Array.isArray(raw.points)) return null;
393
+ const points = raw.points
394
+ .map((point) => parseCanvasPosition(point))
395
+ .filter((point): point is { x: number; y: number } => point !== null);
396
+ const bounds = parseCanvasRect(raw.bounds);
397
+ if (points.length < 2 || !bounds) return null;
398
+
399
+ return {
400
+ id: raw.id,
401
+ type: 'freehand',
402
+ points,
403
+ bounds,
404
+ color: typeof raw.color === 'string' ? raw.color : '#f97316',
405
+ width: typeof raw.width === 'number' ? raw.width : 4,
406
+ ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
407
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
408
+ };
409
+ }
410
+
382
411
  // ── SSE event handlers ───────────────────────────────────────
383
412
  function handleConnected(data: Record<string, unknown>): void {
384
413
  sessionId.value = (data.sessionId as string) || '';
@@ -801,6 +830,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
801
830
  | {
802
831
  nodes?: Array<Record<string, unknown>>;
803
832
  edges?: Array<Record<string, unknown>>;
833
+ annotations?: Array<Record<string, unknown>>;
804
834
  viewport?: Record<string, unknown>;
805
835
  }
806
836
  | undefined;
@@ -814,6 +844,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
814
844
  const serverEdges = Array.isArray(layout.edges)
815
845
  ? layout.edges.map(parseCanvasEdge).filter((edge): edge is CanvasEdge => edge !== null)
816
846
  : Array.from(edges.value.values());
847
+ const serverAnnotations = Array.isArray(layout.annotations)
848
+ ? layout.annotations.map(parseCanvasAnnotation).filter((annotation): annotation is CanvasAnnotation => annotation !== null)
849
+ : undefined;
817
850
  const nextViewport = layout.viewport
818
851
  ? {
819
852
  x: typeof layout.viewport.x === 'number' ? layout.viewport.x : 0,
@@ -827,6 +860,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
827
860
  ...(nextViewport ? { viewport: nextViewport } : {}),
828
861
  nodes: serverNodes,
829
862
  edges: serverEdges,
863
+ ...(serverAnnotations ? { annotations: serverAnnotations } : {}),
830
864
  }, { applyViewport: shouldApplyViewport });
831
865
 
832
866
  syncAttentionFromSse({ event: 'canvas-layout-update', data });
@@ -50,6 +50,7 @@
50
50
  --c-accent-hover: #6ECAFF;
51
51
  --c-warn-hover: #f5d06b;
52
52
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
53
+ --c-annotation: #F4EFE6;
53
54
  /* ── Non-color tokens ────────────────────────────────────── */
54
55
  --font: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
55
56
  --mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
@@ -109,6 +110,7 @@
109
110
  --c-accent-hover: #1588CE;
110
111
  --c-warn-hover: #dab040;
111
112
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(8, 21, 36, 0.02));
113
+ --c-annotation: #081524;
112
114
  }
113
115
 
114
116
  :root[data-theme="high-contrast"] {
@@ -162,6 +164,7 @@
162
164
  --c-accent-hover: #33ffff;
163
165
  --c-warn-hover: #ffff33;
164
166
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
167
+ --c-annotation: #ffff00;
165
168
  }
166
169
 
167
170
  * {
@@ -1471,6 +1474,28 @@ body,
1471
1474
  z-index: 9998;
1472
1475
  }
1473
1476
 
1477
+ .annotation-layer {
1478
+ position: absolute;
1479
+ inset: 0;
1480
+ width: 1px;
1481
+ height: 1px;
1482
+ overflow: visible;
1483
+ pointer-events: none;
1484
+ z-index: 45;
1485
+ }
1486
+
1487
+ .annotation-capture-layer {
1488
+ position: absolute;
1489
+ inset: 0;
1490
+ z-index: 9996;
1491
+ pointer-events: none;
1492
+ background: color-mix(in srgb, var(--c-accent) 5%, transparent);
1493
+ }
1494
+
1495
+ .annotation-capture-layer.erasing {
1496
+ background: color-mix(in srgb, var(--c-danger) 6%, transparent);
1497
+ }
1498
+
1474
1499
  /* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
1475
1500
  .drop-zone-overlay {
1476
1501
  position: absolute;
@@ -41,6 +41,22 @@ export interface CanvasEdge {
41
41
  animated?: boolean;
42
42
  }
43
43
 
44
+ export interface CanvasAnnotationPoint {
45
+ x: number;
46
+ y: number;
47
+ }
48
+
49
+ export interface CanvasAnnotation {
50
+ id: string;
51
+ type: 'freehand';
52
+ points: CanvasAnnotationPoint[];
53
+ bounds: { x: number; y: number; width: number; height: number };
54
+ color: string;
55
+ width: number;
56
+ label?: string;
57
+ createdAt: string;
58
+ }
59
+
44
60
  export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
45
61
 
46
62
  // ── Shared constants for node type display ──────────────────
@@ -93,4 +109,5 @@ export interface CanvasLayout {
93
109
  viewport: ViewportState;
94
110
  nodes: CanvasNodeState[];
95
111
  edges: CanvasEdge[];
112
+ annotations?: CanvasAnnotation[];
96
113
  }
@@ -106,6 +106,7 @@ export interface CanvasAccess {
106
106
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
107
107
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
108
108
  removeNode(id: string): Promise<void>;
109
+ removeAnnotation(id: string): Promise<boolean>;
109
110
  addEdge(input: AddEdgeInput): Promise<string>;
110
111
  removeEdge(id: string): Promise<void>;
111
112
  createGroup(input: CreateGroupInput): Promise<string>;
@@ -203,6 +204,10 @@ class LocalCanvasAccess implements CanvasAccess {
203
204
  this.canvas.removeNode(id);
204
205
  }
205
206
 
207
+ async removeAnnotation(id: string): Promise<boolean> {
208
+ return this.canvas.removeAnnotation(id);
209
+ }
210
+
206
211
  async addEdge(input: AddEdgeInput): Promise<string> {
207
212
  return this.canvas.addEdge(input);
208
213
  }
@@ -455,6 +460,11 @@ class RemoteCanvasAccess implements CanvasAccess {
455
460
  await this.requestJson<unknown>('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
456
461
  }
457
462
 
463
+ async removeAnnotation(id: string): Promise<boolean> {
464
+ const response = await this.requestJson<{ ok?: boolean }>('DELETE', `/api/canvas/annotation/${encodeURIComponent(id)}`);
465
+ return response.ok === true;
466
+ }
467
+
458
468
  async addEdge(input: AddEdgeInput): Promise<string> {
459
469
  const response = await this.requestJson<{ id?: string }>('POST', '/api/canvas/edge', input);
460
470
  if (!response.id) throw new Error('Canvas edge response did not include an edge id.');
package/src/mcp/server.ts CHANGED
@@ -29,7 +29,13 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
32
- import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
32
+ import {
33
+ getCanvasNodeTitle,
34
+ serializeCanvasLayoutForAgent,
35
+ serializeCanvasNode,
36
+ serializeCanvasNodeForAgent,
37
+ summarizeCanvasAnnotationForContext,
38
+ } from '../server/canvas-serialization.js';
33
39
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
34
40
 
35
41
  let canvas: CanvasAccess | null = null;
@@ -191,6 +197,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
191
197
  return {
192
198
  summary: buildSummaryFromLayout(layout, pinnedIds),
193
199
  viewport: layout.viewport,
200
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
194
201
  nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
195
202
  edges: layout.edges.map((edge) => ({
196
203
  id: edge.id,
@@ -204,6 +211,13 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
204
211
  };
205
212
  }
206
213
 
214
+ function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
215
+ return {
216
+ ...serializeCanvasLayoutForAgent(layout),
217
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
218
+ };
219
+ }
220
+
207
221
  function compactBatchValue(value: unknown): unknown {
208
222
  if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
209
223
  const record = value as Record<string, unknown>;
@@ -232,7 +246,7 @@ async function createdNodePayload(c: CanvasAccess, id: string, options: { full?:
232
246
  if (!wantsFullPayload(options)) {
233
247
  return { ok: true, node: compactNodePayload(node), id };
234
248
  }
235
- const serialized = serializeCanvasNode(node);
249
+ const serialized = serializeCanvasNodeForAgent(node);
236
250
  return { ok: true, node: serialized, ...serialized };
237
251
  }
238
252
 
@@ -248,6 +262,8 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
248
262
  return {
249
263
  totalNodes: layout.nodes.length,
250
264
  totalEdges: layout.edges.length,
265
+ totalAnnotations: (layout.annotations ?? []).length,
266
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
251
267
  nodesByType,
252
268
  pinnedCount: pinned.size,
253
269
  pinnedTitles,
@@ -263,6 +279,7 @@ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['ge
263
279
  return {
264
280
  nodeCount: layout.nodes.length,
265
281
  edgeCount: layout.edges.length,
282
+ annotationCount: (layout.annotations ?? []).length,
266
283
  nodesByType,
267
284
  viewport: layout.viewport,
268
285
  };
@@ -287,7 +304,7 @@ export async function startMcpServer(): Promise<void> {
287
304
  const c = await ensureCanvas();
288
305
  const layout = await c.getLayout();
289
306
  const payload = wantsFullPayload(input)
290
- ? serializeCanvasLayout(layout)
307
+ ? agentSafeFullLayoutPayload(layout)
291
308
  : compactLayoutPayload(layout, await c.getPinnedNodeIds());
292
309
  return {
293
310
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
@@ -313,7 +330,7 @@ export async function startMcpServer(): Promise<void> {
313
330
  isError: true,
314
331
  };
315
332
  }
316
- const payload = wantsFullPayload(input) ? serializeCanvasNode(node) : compactNodePayload(node);
333
+ const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
317
334
  return {
318
335
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
319
336
  };
@@ -902,6 +919,26 @@ export async function startMcpServer(): Promise<void> {
902
919
  },
903
920
  );
904
921
 
922
+ // ── canvas_remove_annotation ─────────────────────────────────────
923
+ server.tool(
924
+ 'canvas_remove_annotation',
925
+ 'Remove a human-drawn canvas annotation by ID.',
926
+ { id: z.string().describe('Annotation ID to remove') },
927
+ async ({ id }) => {
928
+ const c = await ensureCanvas();
929
+ const removed = await c.removeAnnotation(id);
930
+ if (!removed) {
931
+ return {
932
+ content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
933
+ isError: true,
934
+ };
935
+ }
936
+ return {
937
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
938
+ };
939
+ },
940
+ );
941
+
905
942
  // ── canvas_add_edge ────────────────────────────────────────────
906
943
  server.tool(
907
944
  'canvas_add_edge',
@@ -1401,7 +1438,7 @@ export async function startMcpServer(): Promise<void> {
1401
1438
  },
1402
1439
  async () => {
1403
1440
  const c = await ensureCanvas();
1404
- const layout = serializeCanvasLayout(await c.getLayout());
1441
+ const layout = agentSafeFullLayoutPayload(await c.getLayout());
1405
1442
  return {
1406
1443
  contents: [
1407
1444
  {
@@ -1452,7 +1489,7 @@ export async function startMcpServer(): Promise<void> {
1452
1489
  async () => {
1453
1490
  const c = await ensureCanvas();
1454
1491
  const layout = await c.getLayout();
1455
- const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
1492
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()), layout.annotations ?? []);
1456
1493
  return {
1457
1494
  contents: [
1458
1495
  {
@@ -223,6 +223,31 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
223
223
  'Webpage nodes persist `data.provenance` with the source URL and refresh strategy so reopened snapshots can be re-fetched.',
224
224
  ],
225
225
  },
226
+ {
227
+ type: 'html',
228
+ kind: 'node',
229
+ description: 'Sandboxed iframe node rendered from inline HTML.',
230
+ endpoint: '/api/canvas/node',
231
+ mcpTool: 'canvas_add_html_node',
232
+ fields: [
233
+ { name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
234
+ { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
235
+ { name: 'x', type: 'number', required: false, description: 'Optional X position.' },
236
+ { name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
237
+ { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
238
+ { name: 'height', type: 'number', required: false, description: 'Optional node height.' },
239
+ { name: 'strictSize', type: 'boolean', required: false, description: 'Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting.', aliases: ['strict-size', 'scroll-overflow'] },
240
+ ],
241
+ example: {
242
+ type: 'html',
243
+ title: 'HTML Widget',
244
+ html: '<main><h1>Hello from PMX Canvas</h1></main>',
245
+ },
246
+ notes: [
247
+ 'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
248
+ 'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
249
+ ],
250
+ },
226
251
  {
227
252
  type: 'mcp-app',
228
253
  kind: 'node',
@@ -1,5 +1,6 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { canvasState } from './canvas-state.js';
2
- import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
3
+ import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
3
4
  import {
4
5
  normalizeCanvasNodeData,
5
6
  type CanvasNodeProvenance,
@@ -19,6 +20,26 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
19
20
  nodes: SerializedCanvasNode[];
20
21
  }
21
22
 
23
+ export interface CanvasAnnotationSummary {
24
+ id: string;
25
+ type: CanvasAnnotation['type'];
26
+ bounds: CanvasAnnotation['bounds'];
27
+ color: string;
28
+ width: number;
29
+ pointCount: number;
30
+ label: string | null;
31
+ createdAt: string;
32
+ }
33
+
34
+ export interface CanvasAnnotationContextSummary {
35
+ id: string;
36
+ label: string | null;
37
+ bounds: CanvasAnnotation['bounds'];
38
+ targetNodeIds: string[];
39
+ targetNodeTitles: string[];
40
+ target: string;
41
+ }
42
+
22
43
  interface BlobSummary {
23
44
  stored: 'sidecar';
24
45
  path: string;
@@ -27,6 +48,13 @@ interface BlobSummary {
27
48
  sha256: string;
28
49
  }
29
50
 
51
+ interface ExternalMcpAppHtmlSummary {
52
+ omitted: 'external-mcp-app-html';
53
+ resourceUri: string;
54
+ bytes: number;
55
+ sha256: string;
56
+ }
57
+
30
58
  function pickString(value: unknown): string | null {
31
59
  return typeof value === 'string' && value.length > 0 ? value : null;
32
60
  }
@@ -70,6 +98,39 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
70
98
  };
71
99
  }
72
100
 
101
+ function summarizeExternalMcpAppHtml(node: SerializedCanvasNode): Record<string, unknown> {
102
+ const html = node.data.html;
103
+ const resourceUri = node.data.resourceUri;
104
+ if (
105
+ node.type !== 'mcp-app' ||
106
+ node.data.mode !== 'ext-app' ||
107
+ typeof html !== 'string' ||
108
+ html.length === 0 ||
109
+ typeof resourceUri !== 'string' ||
110
+ resourceUri.length === 0
111
+ ) {
112
+ return node.data;
113
+ }
114
+
115
+ return {
116
+ ...node.data,
117
+ html: {
118
+ omitted: 'external-mcp-app-html',
119
+ resourceUri,
120
+ bytes: Buffer.byteLength(html, 'utf-8'),
121
+ sha256: createHash('sha256').update(html).digest('hex'),
122
+ } satisfies ExternalMcpAppHtmlSummary,
123
+ };
124
+ }
125
+
126
+ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode {
127
+ const serialized = serializeCanvasNode(node);
128
+ return {
129
+ ...serialized,
130
+ data: summarizeExternalMcpAppHtml(serialized),
131
+ };
132
+ }
133
+
73
134
  function summarizeBlobValue(value: unknown): unknown {
74
135
  if (!canvasState.isBlobReference(value)) return value;
75
136
  return {
@@ -97,6 +158,13 @@ export function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLay
97
158
  };
98
159
  }
99
160
 
161
+ export function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout {
162
+ return {
163
+ ...layout,
164
+ nodes: layout.nodes.map(serializeCanvasNodeForAgent),
165
+ };
166
+ }
167
+
100
168
  export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout {
101
169
  return {
102
170
  ...layout,
@@ -104,9 +172,55 @@ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): Se
104
172
  };
105
173
  }
106
174
 
175
+ export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary {
176
+ return {
177
+ id: annotation.id,
178
+ type: annotation.type,
179
+ bounds: annotation.bounds,
180
+ color: annotation.color,
181
+ width: annotation.width,
182
+ pointCount: annotation.points.length,
183
+ label: annotation.label ?? null,
184
+ createdAt: annotation.createdAt,
185
+ };
186
+ }
187
+
188
+ function rectsOverlap(
189
+ a: { x: number; y: number; width: number; height: number },
190
+ b: { x: number; y: number; width: number; height: number },
191
+ ): boolean {
192
+ return a.x <= b.x + b.width &&
193
+ a.x + a.width >= b.x &&
194
+ a.y <= b.y + b.height &&
195
+ a.y + a.height >= b.y;
196
+ }
197
+
198
+ export function summarizeCanvasAnnotationForContext(
199
+ annotation: CanvasAnnotation,
200
+ nodes: CanvasNodeState[],
201
+ ): CanvasAnnotationContextSummary {
202
+ const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
203
+ x: node.position.x,
204
+ y: node.position.y,
205
+ width: node.size.width,
206
+ height: node.size.height,
207
+ }));
208
+ const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
209
+ return {
210
+ id: annotation.id,
211
+ label: annotation.label ?? null,
212
+ bounds: annotation.bounds,
213
+ targetNodeIds: targetNodes.map((node) => node.id),
214
+ targetNodeTitles,
215
+ target: targetNodeTitles.length > 0 ? targetNodeTitles.join(', ') : 'empty canvas region',
216
+ };
217
+ }
218
+
107
219
  export interface CanvasSummary {
108
220
  totalNodes: number;
109
221
  totalEdges: number;
222
+ totalAnnotations: number;
223
+ annotations: CanvasAnnotationContextSummary[];
110
224
  nodesByType: Record<string, number>;
111
225
  pinnedCount: number;
112
226
  pinnedTitles: string[];
@@ -130,6 +244,8 @@ export function buildCanvasSummary(): CanvasSummary {
130
244
  return {
131
245
  totalNodes: layout.nodes.length,
132
246
  totalEdges: layout.edges.length,
247
+ totalAnnotations: layout.annotations.length,
248
+ annotations: layout.annotations.map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
133
249
  nodesByType: typeCounts,
134
250
  pinnedCount: pinnedIds.size,
135
251
  pinnedTitles,