pmx-canvas 0.1.13 → 0.1.15

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 (58) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +137 -87
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  9. package/dist/types/client/state/canvas-store.d.ts +5 -1
  10. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +12 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/diagram-presets.d.ts +4 -0
  21. package/dist/types/server/index.d.ts +21 -3
  22. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +18 -0
  24. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +43 -0
  27. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  28. package/src/cli/agent.ts +66 -5
  29. package/src/cli/index.ts +2 -23
  30. package/src/client/canvas/AttentionHistory.tsx +14 -1
  31. package/src/client/canvas/CanvasNode.tsx +1 -1
  32. package/src/client/canvas/CanvasViewport.tsx +3 -0
  33. package/src/client/canvas/DockedNode.tsx +110 -12
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +8 -3
  35. package/src/client/canvas/Minimap.tsx +1 -0
  36. package/src/client/icons.tsx +1 -0
  37. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  38. package/src/client/nodes/HtmlNode.tsx +151 -0
  39. package/src/client/nodes/McpAppNode.tsx +2 -2
  40. package/src/client/state/canvas-store.ts +24 -2
  41. package/src/client/state/intent-bridge.ts +4 -3
  42. package/src/client/state/sse-bridge.ts +2 -0
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +199 -26
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +55 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/diagram-presets.ts +45 -25
  54. package/src/server/index.ts +64 -7
  55. package/src/server/mcp-app-runtime.ts +15 -5
  56. package/src/server/server.ts +169 -63
  57. package/src/server/web-artifacts.ts +116 -3
  58. package/src/shared/canvas-node-kind.ts +14 -0
@@ -0,0 +1,151 @@
1
+ import { useMemo } from 'preact/hooks';
2
+ import { getCanvasTokens } from '../theme/tokens';
3
+ import type { CanvasNodeState } from '../types';
4
+
5
+ /**
6
+ * Strip characters that could break out of a CSS custom-property value context
7
+ * before interpolating into a `<style>` block. The expected token shape is a
8
+ * CSS color (`#abc`, `rgb(...)`) or font-family list, neither of which needs
9
+ * `<`, `>`, `{`, `}`, `;`, or backticks. Defense-in-depth against a future
10
+ * scenario where theme tokens become runtime-editable.
11
+ */
12
+ function sanitizeCssTokenValue(value: string): string {
13
+ return value.replace(/[<>{};`\\]/g, '').trim();
14
+ }
15
+
16
+ /**
17
+ * Build a `<style>` block that exposes canvas theme tokens to the iframe under
18
+ * both the canonical `--c-*` names and common `--color-*` aliases. Also sets
19
+ * sensible body defaults (font, bg, color) so authored HTML inherits the look.
20
+ */
21
+ function buildThemeStyleBlock(): string {
22
+ const raw = getCanvasTokens();
23
+ const t = {
24
+ bg: sanitizeCssTokenValue(raw.bg),
25
+ panel: sanitizeCssTokenValue(raw.panel),
26
+ panelSoft: sanitizeCssTokenValue(raw.panelSoft),
27
+ line: sanitizeCssTokenValue(raw.line),
28
+ text: sanitizeCssTokenValue(raw.text),
29
+ textSoft: sanitizeCssTokenValue(raw.textSoft),
30
+ muted: sanitizeCssTokenValue(raw.muted),
31
+ dim: sanitizeCssTokenValue(raw.dim),
32
+ accent: sanitizeCssTokenValue(raw.accent),
33
+ ok: sanitizeCssTokenValue(raw.ok),
34
+ warn: sanitizeCssTokenValue(raw.warn),
35
+ warnAlt: sanitizeCssTokenValue(raw.warnAlt),
36
+ danger: sanitizeCssTokenValue(raw.danger),
37
+ purple: sanitizeCssTokenValue(raw.purple),
38
+ font: sanitizeCssTokenValue(raw.font),
39
+ mono: sanitizeCssTokenValue(raw.mono),
40
+ };
41
+ return `
42
+ :root {
43
+ --c-bg: ${t.bg};
44
+ --c-panel: ${t.panel};
45
+ --c-panel-soft: ${t.panelSoft};
46
+ --c-line: ${t.line};
47
+ --c-text: ${t.text};
48
+ --c-text-soft: ${t.textSoft};
49
+ --c-muted: ${t.muted};
50
+ --c-dim: ${t.dim};
51
+ --c-accent: ${t.accent};
52
+ --c-ok: ${t.ok};
53
+ --c-warn: ${t.warn};
54
+ --c-warn-alt: ${t.warnAlt};
55
+ --c-danger: ${t.danger};
56
+ --c-purple: ${t.purple};
57
+
58
+ /* Common aliases authored HTML might use. */
59
+ --color-bg: ${t.bg};
60
+ --color-panel: ${t.panel};
61
+ --color-surface: ${t.panelSoft};
62
+ --color-border: ${t.line};
63
+ --color-text: ${t.text};
64
+ --color-text-primary: ${t.text};
65
+ --color-text-secondary: ${t.textSoft};
66
+ --color-text-muted: ${t.muted};
67
+ --color-text-dim: ${t.dim};
68
+ --color-accent: ${t.accent};
69
+ --color-success: ${t.ok};
70
+ --color-warning: ${t.warn};
71
+ --color-danger: ${t.danger};
72
+
73
+ --font: ${t.font};
74
+ --font-sans: ${t.font};
75
+ --font-mono: ${t.mono};
76
+
77
+ color-scheme: dark light;
78
+ }
79
+ html, body {
80
+ margin: 0;
81
+ padding: 0;
82
+ background: ${t.bg};
83
+ color: ${t.text};
84
+ font-family: ${t.font || 'system-ui, sans-serif'};
85
+ font-size: 14px;
86
+ line-height: 1.5;
87
+ }
88
+ body { padding: 16px; box-sizing: border-box; }
89
+ a { color: ${t.accent}; }
90
+ `;
91
+ }
92
+
93
+ /**
94
+ * Inject the theme style block into the user-supplied HTML. If the document has
95
+ * a `<head>`, inject at the top of head; otherwise wrap the content in a full
96
+ * document. Returns a complete HTML string suitable for `srcdoc`.
97
+ */
98
+ function buildSrcDoc(userHtml: string): string {
99
+ const styleBlock = `<style data-pmx-canvas-theme>${buildThemeStyleBlock()}</style>`;
100
+ const trimmed = userHtml.trim();
101
+ const isFullDoc = /<html[\s>]/i.test(trimmed);
102
+ if (isFullDoc) {
103
+ if (/<head[\s>]/i.test(trimmed)) {
104
+ return trimmed.replace(/<head([^>]*)>/i, `<head$1>${styleBlock}`);
105
+ }
106
+ // Has <html> but no <head> — inject one.
107
+ return trimmed.replace(/<html([^>]*)>/i, `<html$1><head>${styleBlock}</head>`);
108
+ }
109
+ // Fragment — wrap in full document.
110
+ return `<!doctype html><html><head><meta charset="utf-8">${styleBlock}</head><body>${userHtml}</body></html>`;
111
+ }
112
+
113
+ export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
114
+ const html = typeof node.data.html === 'string'
115
+ ? node.data.html
116
+ : typeof node.data.content === 'string'
117
+ ? node.data.content
118
+ : '';
119
+ const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
120
+
121
+ if (!html) {
122
+ return (
123
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
124
+ No HTML content set
125
+ </div>
126
+ );
127
+ }
128
+
129
+ // SECURITY: sandbox is intentionally `allow-scripts` ONLY. Do NOT add
130
+ // `allow-same-origin` (would grant the iframe access to parent localStorage
131
+ // and credentialed requests to the canvas origin), `allow-top-navigation`
132
+ // (would let scripts redirect the parent window), or `allow-forms` (would
133
+ // let the iframe POST back to the host). The whole html-node tier assumes
134
+ // arbitrary author code runs inside this exact sandbox.
135
+ return (
136
+ <iframe
137
+ title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
138
+ sandbox="allow-scripts"
139
+ srcdoc={srcDoc}
140
+ style={{
141
+ width: '100%',
142
+ height: '100%',
143
+ minHeight: expanded ? '70vh' : '300px',
144
+ border: 'none',
145
+ background: 'var(--c-bg)',
146
+ borderRadius: '6px',
147
+ display: 'block',
148
+ }}
149
+ />
150
+ );
151
+ }
@@ -13,9 +13,9 @@ function withTheme(url: string): string {
13
13
  }
14
14
  }
15
15
 
16
- export function McpAppNode({ node }: { node: CanvasNodeState }) {
16
+ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
17
17
  if (node.data.mode === 'ext-app') {
18
- return <ExtAppFrame node={node} />;
18
+ return <ExtAppFrame node={node} expanded={expanded} />;
19
19
  }
20
20
 
21
21
  const url = withTheme((node.data.url as string) || '');
@@ -277,6 +277,28 @@ export function toggleCollapsed(id: string): void {
277
277
  updateNode(id, { collapsed: !existing.collapsed });
278
278
  }
279
279
 
280
+ // Collapse every docked context node. Used to enforce mutual exclusion between
281
+ // the Context side panel and the Updates side panel (they share the same
282
+ // right-edge anchor and would otherwise visually collide).
283
+ export function collapseDockedContextNodes(): void {
284
+ for (const node of nodes.value.values()) {
285
+ if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
286
+ updateNode(node.id, { collapsed: true });
287
+ }
288
+ }
289
+ }
290
+
291
+ // True iff at least one docked context node is currently expanded. Used by the
292
+ // Updates pill to hide itself while the Context panel is open.
293
+ export const hasOpenDockedContextPanel = computed(() => {
294
+ for (const node of nodes.value.values()) {
295
+ if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
296
+ return true;
297
+ }
298
+ }
299
+ return false;
300
+ });
301
+
280
302
  export function dockNode(id: string, position: 'left' | 'right'): void {
281
303
  const existing = nodes.value.get(id);
282
304
  if (!existing) return;
@@ -411,7 +433,7 @@ export function cancelViewportAnimation(): void {
411
433
  // ── Persistence ───────────────────────────────────────────────
412
434
  const STORAGE_KEY = 'pmx-canvas-layout';
413
435
 
414
- export function persistLayout(): void {
436
+ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
415
437
  try {
416
438
  const allNodes = Array.from(nodes.value.values());
417
439
  const nodeUpdates = allNodes.map((n) => ({
@@ -444,7 +466,7 @@ export function persistLayout(): void {
444
466
  contextPinnedNodeIds: Array.from(contextPinnedNodeIds.value),
445
467
  };
446
468
  localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
447
- void pushCanvasUpdate(nodeUpdates);
469
+ void pushCanvasUpdate(nodeUpdates, options);
448
470
  } catch (error) {
449
471
  logCanvasStoreError('persistLayout', error);
450
472
  }
@@ -101,7 +101,7 @@ export async function openWorkbenchFile(path: string): Promise<{ ok: boolean }>
101
101
 
102
102
  /** Fetch canvas state from server. */
103
103
  export async function fetchCanvasState(): Promise<Record<string, unknown>> {
104
- return requestJson('fetchCanvasState', '/api/canvas/state', {});
104
+ return requestJson('fetchCanvasState', '/api/canvas/state?includeBlobs=true', {});
105
105
  }
106
106
 
107
107
  /** Fetch available slash commands for prompt completion. */
@@ -148,11 +148,12 @@ export async function pushCanvasUpdate(
148
148
  collapsed?: boolean;
149
149
  dockPosition?: 'left' | 'right' | null;
150
150
  }>,
151
+ options: { recordHistory?: boolean } = {},
151
152
  ): Promise<void> {
152
153
  await requestBestEffort('pushCanvasUpdate', '/api/canvas/update', {
153
154
  method: 'POST',
154
155
  headers: { 'Content-Type': 'application/json' },
155
- body: JSON.stringify({ updates }),
156
+ body: JSON.stringify({ updates, ...(options.recordHistory === false ? { recordHistory: false } : {}) }),
156
157
  });
157
158
  }
158
159
 
@@ -286,7 +287,7 @@ export interface CanvasSnapshotInfo {
286
287
  }
287
288
 
288
289
  export async function listSnapshots(): Promise<CanvasSnapshotInfo[]> {
289
- return requestJson<CanvasSnapshotInfo[]>('listSnapshots', '/api/canvas/snapshots', []);
290
+ return requestJson<CanvasSnapshotInfo[]>('listSnapshots', '/api/canvas/snapshots?all=true', []);
290
291
  }
291
292
 
292
293
  export async function saveSnapshot(name: string): Promise<{ ok: boolean; snapshot?: CanvasSnapshotInfo }> {
@@ -83,6 +83,7 @@ const DEFAULT_POSITIONS: Record<
83
83
  trace: { x: 40, y: 900, w: 200, h: 56 },
84
84
  file: { x: 380, y: 80, w: 720, h: 600 },
85
85
  image: { x: 380, y: 80, w: 720, h: 520 },
86
+ html: { x: 380, y: 80, w: 720, h: 640 },
86
87
  group: { x: 220, y: 60, w: 840, h: 560 },
87
88
  prompt: { x: 380, y: 1260, w: 520, h: 400 },
88
89
  response: { x: 380, y: 1480, w: 720, h: 400 },
@@ -529,6 +530,7 @@ function handleExtAppOpen(data: Record<string, unknown>): void {
529
530
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
530
531
  ensureExtAppNode({
531
532
  toolCallId: data.toolCallId,
533
+ ...(typeof data.nodeId === 'string' && data.nodeId.length > 0 ? { nodeId: data.nodeId } : {}),
532
534
  title: data.title,
533
535
  html: data.html,
534
536
  toolInput: data.toolInput,
@@ -1844,6 +1844,147 @@ body,
1844
1844
  max-width: 200px;
1845
1845
  }
1846
1846
 
1847
+ /* Context dock — collapsed pill mirrors Updates pill, sits above it */
1848
+ .context-dock-tab {
1849
+ position: fixed;
1850
+ top: 92px;
1851
+ right: 0;
1852
+ display: flex;
1853
+ align-items: center;
1854
+ gap: 8px;
1855
+ padding: 8px 12px 8px 14px;
1856
+ background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
1857
+ backdrop-filter: blur(16px);
1858
+ border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
1859
+ border-right: 0;
1860
+ border-radius: 14px 0 0 14px;
1861
+ box-shadow: 0 12px 36px var(--c-shadow);
1862
+ color: var(--c-text);
1863
+ cursor: pointer;
1864
+ font: inherit;
1865
+ font-size: 11px;
1866
+ font-weight: 600;
1867
+ letter-spacing: 0.08em;
1868
+ text-transform: uppercase;
1869
+ z-index: 60;
1870
+ }
1871
+
1872
+ .context-dock-tab:hover {
1873
+ border-color: color-mix(in srgb, var(--c-accent) 40%, var(--c-line) 60%);
1874
+ color: var(--c-accent);
1875
+ }
1876
+
1877
+ .context-dock-tab svg {
1878
+ display: block;
1879
+ color: var(--c-accent);
1880
+ flex-shrink: 0;
1881
+ }
1882
+
1883
+ .context-dock-tab-label {
1884
+ white-space: nowrap;
1885
+ }
1886
+
1887
+ .context-dock-tab-badge {
1888
+ min-width: 18px;
1889
+ height: 18px;
1890
+ padding: 0 5px;
1891
+ display: inline-flex;
1892
+ align-items: center;
1893
+ justify-content: center;
1894
+ border-radius: 9px;
1895
+ background: var(--c-accent);
1896
+ color: var(--c-contrast-fg);
1897
+ font-size: 10px;
1898
+ font-weight: 700;
1899
+ letter-spacing: 0;
1900
+ text-transform: none;
1901
+ }
1902
+
1903
+ /* Context dock — expanded panel anchored top-right edge.
1904
+ Mutually exclusive with the Updates panel (see DockedNode.tsx and
1905
+ AttentionHistory.tsx) — opening one collapses the other, so they can both
1906
+ share the same right: 18px anchor without overlapping. */
1907
+ .context-dock-panel {
1908
+ position: fixed;
1909
+ top: 92px;
1910
+ right: 18px;
1911
+ width: min(360px, calc(100vw - 24px));
1912
+ max-height: calc(100vh - 110px);
1913
+ display: flex;
1914
+ flex-direction: column;
1915
+ gap: 10px;
1916
+ padding: 14px;
1917
+ background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
1918
+ backdrop-filter: blur(16px);
1919
+ border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
1920
+ border-radius: 18px;
1921
+ box-shadow: 0 14px 36px var(--c-shadow), 0 0 0 1px color-mix(in srgb, var(--c-accent) 8%, transparent);
1922
+ z-index: 10001;
1923
+ overflow: hidden;
1924
+ }
1925
+
1926
+ .context-dock-header {
1927
+ display: flex;
1928
+ align-items: flex-start;
1929
+ justify-content: space-between;
1930
+ gap: 8px;
1931
+ padding: 2px 2px 4px;
1932
+ flex-shrink: 0;
1933
+ }
1934
+
1935
+ .context-dock-header-text {
1936
+ display: flex;
1937
+ flex-direction: column;
1938
+ gap: 2px;
1939
+ min-width: 0;
1940
+ }
1941
+
1942
+ .context-dock-title {
1943
+ font-size: 12px;
1944
+ font-weight: 700;
1945
+ letter-spacing: 0.08em;
1946
+ text-transform: uppercase;
1947
+ color: var(--c-text);
1948
+ }
1949
+
1950
+ .context-dock-subtitle {
1951
+ font-size: 11px;
1952
+ color: var(--c-dim);
1953
+ }
1954
+
1955
+ .context-dock-controls {
1956
+ display: flex;
1957
+ gap: 4px;
1958
+ flex-shrink: 0;
1959
+ }
1960
+
1961
+ .context-dock-icon-button {
1962
+ width: 22px;
1963
+ height: 22px;
1964
+ border: 0;
1965
+ border-radius: 6px;
1966
+ background: transparent;
1967
+ color: var(--c-dim);
1968
+ font-size: 16px;
1969
+ line-height: 1;
1970
+ cursor: pointer;
1971
+ display: grid;
1972
+ place-items: center;
1973
+ padding: 0;
1974
+ }
1975
+
1976
+ .context-dock-icon-button:hover {
1977
+ color: var(--c-text);
1978
+ background: var(--c-surface-hover);
1979
+ }
1980
+
1981
+ .context-dock-body {
1982
+ flex: 1 1 auto;
1983
+ min-height: 0;
1984
+ overflow-y: auto;
1985
+ padding-right: 2px;
1986
+ }
1987
+
1847
1988
  .attention-history {
1848
1989
  position: fixed;
1849
1990
  top: 146px;
@@ -20,6 +20,7 @@ export interface CanvasNodeState {
20
20
  | 'trace'
21
21
  | 'file'
22
22
  | 'image'
23
+ | 'html'
23
24
  | 'group';
24
25
  position: { x: number; y: number };
25
26
  size: { width: number; height: number };
@@ -58,6 +59,7 @@ export const TYPE_LABELS: Record<CanvasNodeState['type'], string> = {
58
59
  trace: 'TRACE',
59
60
  file: 'FILE',
60
61
  image: 'IMG',
62
+ html: 'HTML',
61
63
  group: 'GROUP',
62
64
  };
63
65
 
@@ -72,6 +74,7 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
72
74
  'ledger',
73
75
  'file',
74
76
  'image',
77
+ 'html',
75
78
  ]);
76
79
 
77
80
  export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
@@ -18,6 +18,7 @@ type OpenMcpAppResult = Awaited<ReturnType<PmxCanvas['openMcpApp']>>;
18
18
  type AddDiagramInput = Parameters<PmxCanvas['addDiagram']>[0];
19
19
  type AddJsonRenderNodeInput = Parameters<PmxCanvas['addJsonRenderNode']>[0];
20
20
  type AddJsonRenderNodeResult = ReturnType<PmxCanvas['addJsonRenderNode']>;
21
+ type AddHtmlNodeInput = Parameters<PmxCanvas['addHtmlNode']>[0];
21
22
  type AddGraphNodeInput = Parameters<PmxCanvas['addGraphNode']>[0];
22
23
  type AddGraphNodeResult = ReturnType<PmxCanvas['addGraphNode']>;
23
24
  type UpdateNodePatch = Parameters<PmxCanvas['updateNode']>[1];
@@ -34,8 +35,11 @@ type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
34
35
  type SetContextPinsResult = ReturnType<PmxCanvas['setContextPins']>;
35
36
  type RunBatchInput = Parameters<PmxCanvas['runBatch']>[0];
36
37
  type RunBatchResult = Awaited<ReturnType<PmxCanvas['runBatch']>>;
38
+ type SnapshotListOptions = Parameters<PmxCanvas['listSnapshots']>[0];
37
39
  type SnapshotList = ReturnType<PmxCanvas['listSnapshots']>;
38
40
  type DeleteSnapshotResult = ReturnType<PmxCanvas['deleteSnapshot']>;
41
+ type GcSnapshotsOptions = Parameters<PmxCanvas['gcSnapshots']>[0];
42
+ type GcSnapshotsResult = ReturnType<PmxCanvas['gcSnapshots']>;
39
43
  type DiffSnapshotResult = ReturnType<PmxCanvas['diffSnapshot']>;
40
44
  type CodeGraphResult = ReturnType<PmxCanvas['getCodeGraph']>;
41
45
  type ValidationResult = ReturnType<PmxCanvas['validate']>;
@@ -97,6 +101,7 @@ export interface CanvasAccess {
97
101
  openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult>;
98
102
  addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult>;
99
103
  addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult>;
104
+ addHtmlNode(input: AddHtmlNodeInput): Promise<string>;
100
105
  addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult>;
101
106
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
102
107
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
@@ -117,10 +122,11 @@ export interface CanvasAccess {
117
122
  setContextPins(nodeIds: string[], mode?: 'set' | 'add' | 'remove'): Promise<SetContextPinsResult>;
118
123
  getPinnedNodeIds(): Promise<string[]>;
119
124
  runBatch(operations: RunBatchInput): Promise<RunBatchResult>;
120
- listSnapshots(): Promise<SnapshotList>;
125
+ listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList>;
121
126
  saveSnapshot(name: string): Promise<CanvasSnapshot | null>;
122
127
  restoreSnapshot(id: string): Promise<{ ok: boolean }>;
123
128
  deleteSnapshot(id: string): Promise<DeleteSnapshotResult>;
129
+ gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult>;
124
130
  diffSnapshot(idOrName: string): Promise<DiffSnapshotResult>;
125
131
  getCodeGraph(): Promise<CodeGraphResult>;
126
132
  validate(): Promise<ValidationResult>;
@@ -177,6 +183,10 @@ class LocalCanvasAccess implements CanvasAccess {
177
183
  return this.canvas.addJsonRenderNode(input);
178
184
  }
179
185
 
186
+ async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
187
+ return this.canvas.addHtmlNode(input);
188
+ }
189
+
180
190
  async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
181
191
  return this.canvas.addGraphNode(input);
182
192
  }
@@ -257,8 +267,8 @@ class LocalCanvasAccess implements CanvasAccess {
257
267
  return await this.canvas.runBatch(operations);
258
268
  }
259
269
 
260
- async listSnapshots(): Promise<SnapshotList> {
261
- return this.canvas.listSnapshots();
270
+ async listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList> {
271
+ return this.canvas.listSnapshots(options);
262
272
  }
263
273
 
264
274
  async saveSnapshot(name: string): Promise<CanvasSnapshot | null> {
@@ -273,6 +283,10 @@ class LocalCanvasAccess implements CanvasAccess {
273
283
  return this.canvas.deleteSnapshot(id);
274
284
  }
275
285
 
286
+ async gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult> {
287
+ return this.canvas.gcSnapshots(options);
288
+ }
289
+
276
290
  async diffSnapshot(idOrName: string): Promise<DiffSnapshotResult> {
277
291
  return this.canvas.diffSnapshot(idOrName);
278
292
  }
@@ -359,11 +373,11 @@ class RemoteCanvasAccess implements CanvasAccess {
359
373
  }
360
374
 
361
375
  async getLayout(): Promise<CanvasLayout> {
362
- return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state');
376
+ return await this.requestJson<CanvasLayout>('GET', '/api/canvas/state?includeBlobs=true');
363
377
  }
364
378
 
365
379
  async getNode(id: string): Promise<CanvasNodeState | undefined> {
366
- const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}`);
380
+ const response = await fetch(`${this.remoteBaseUrl}/api/canvas/node/${encodeURIComponent(id)}?includeBlobs=true`);
367
381
  if (response.status === 404) return undefined;
368
382
  const text = await response.text();
369
383
  let parsed: unknown = undefined;
@@ -415,6 +429,10 @@ class RemoteCanvasAccess implements CanvasAccess {
415
429
  return { id, url: response.url, spec: response.spec };
416
430
  }
417
431
 
432
+ async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
433
+ return await this.requestNodeId('POST', '/api/canvas/node', { type: 'html', ...input });
434
+ }
435
+
418
436
  async addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult> {
419
437
  const response = await this.requestJson<GraphNodeResponse>('POST', '/api/canvas/graph', {
420
438
  ...input,
@@ -527,8 +545,13 @@ class RemoteCanvasAccess implements CanvasAccess {
527
545
  return await this.requestJson<RunBatchResult>('POST', '/api/canvas/batch', { operations });
528
546
  }
529
547
 
530
- async listSnapshots(): Promise<SnapshotList> {
531
- return await this.requestJson<SnapshotList>('GET', '/api/canvas/snapshots');
548
+ async listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList> {
549
+ const params = new URLSearchParams();
550
+ if (typeof options?.limit === 'number') params.set('limit', String(options.limit));
551
+ if (options?.query) params.set('q', options.query);
552
+ if (options?.all) params.set('all', 'true');
553
+ const query = params.size > 0 ? `?${params.toString()}` : '';
554
+ return await this.requestJson<SnapshotList>('GET', `/api/canvas/snapshots${query}`);
532
555
  }
533
556
 
534
557
  async saveSnapshot(name: string): Promise<CanvasSnapshot | null> {
@@ -544,6 +567,10 @@ class RemoteCanvasAccess implements CanvasAccess {
544
567
  return await this.requestJson<DeleteSnapshotResult>('DELETE', `/api/canvas/snapshots/${encodeURIComponent(id)}`);
545
568
  }
546
569
 
570
+ async gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult> {
571
+ return await this.requestJson<GcSnapshotsResult>('POST', '/api/canvas/snapshots/gc', options ?? {});
572
+ }
573
+
547
574
  async diffSnapshot(idOrName: string): Promise<DiffSnapshotResult> {
548
575
  return await this.requestJson<DiffSnapshotResult>('GET', `/api/canvas/snapshots/${encodeURIComponent(idOrName)}/diff`);
549
576
  }