pmx-canvas 0.1.3 → 0.1.4

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.
@@ -39,9 +39,10 @@ export declare const tooltipStyle: {
39
39
  fontSize: number;
40
40
  };
41
41
  /** Shared wrapper for cartesian charts (Line + Bar). */
42
- export declare function CartesianChart({ props, children, }: {
42
+ export declare function CartesianChart({ props, children, className, }: {
43
43
  props: CartesianChartProps;
44
44
  children: (data: Record<string, unknown>[]) => ReactNode;
45
+ className?: string;
45
46
  }): import("react/jsx-runtime").JSX.Element;
46
47
  declare function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>): import("react/jsx-runtime").JSX.Element;
47
48
  declare function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
2
2
  import { type CanvasNodeProvenance } from './canvas-provenance.js';
3
3
  export interface SerializedCanvasNode extends CanvasNodeState {
4
+ kind: string;
4
5
  title: string | null;
5
6
  content: string | null;
6
7
  path: string | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolve the canvas node ID for a given ext-app `toolCallId`.
3
+ *
4
+ * v0.1.4 fixed a long-standing `ext-app-ext-app-…` double-prefix bug where
5
+ * both `nodeId` and `toolCallId` carried the `ext-app-` prefix. This helper
6
+ * encodes the lookup contract so it doesn't drift between the
7
+ * `PmxCanvas` SDK class and the HTTP server.
8
+ *
9
+ * Resolution order:
10
+ * 1. The direct prefixed form (`ext-app-<toolCallId>` if not already
11
+ * prefixed, otherwise `toolCallId` as-is).
12
+ * 2. The legacy `ext-app-ext-app-…` form, for canvases persisted before
13
+ * v0.1.4 and still on disk. Remove this fallback in v0.2.x.
14
+ * 3. A scan of the layout for any `mcp-app` ext-app node carrying that
15
+ * `toolCallId` in its data.
16
+ */
17
+ import type { CanvasNodeState } from './canvas-state.js';
18
+ export interface ExtAppLookupSource {
19
+ getNode(id: string): CanvasNodeState | undefined;
20
+ listNodes(): readonly CanvasNodeState[];
21
+ }
22
+ export declare function findCanvasExtAppNodeId(toolCallId: string, source: ExtAppLookupSource): string | null;
@@ -195,6 +195,7 @@ export declare class PmxCanvas extends EventEmitter {
195
195
  height?: number;
196
196
  }): Promise<{
197
197
  ok: true;
198
+ id?: string;
198
199
  nodeId: string | null;
199
200
  toolCallId: string;
200
201
  sessionId: string;
@@ -202,6 +203,7 @@ export declare class PmxCanvas extends EventEmitter {
202
203
  }>;
203
204
  addDiagram(input: DiagramPresetOpenInput): Promise<{
204
205
  ok: true;
206
+ id?: string;
205
207
  nodeId: string | null;
206
208
  toolCallId: string;
207
209
  sessionId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -84,8 +84,8 @@ pmx-canvas layout # Full canvas state
84
84
  pmx-canvas status # Quick summary
85
85
  pmx-canvas node add --type markdown --title "Plan"
86
86
  pmx-canvas node add --type webpage --url https://example.com/docs
87
- pmx-canvas node add --type web-artifact --title "Dashboard" --app-file ./App.tsx
88
87
  pmx-canvas node add --type graph --graph-type bar --data '[{"x":"a","y":1}]' --x-key x --y-key y
88
+ pmx-canvas node add --type graph --graphType bar --data '[{"x":"a","y":1}]' --xKey x --yKey y
89
89
  pmx-canvas external-app add --kind excalidraw --title "Diagram"
90
90
  pmx-canvas node add --help --type webpage --json
91
91
  pmx-canvas node schema --type json-render --component Table --summary
@@ -97,6 +97,8 @@ pmx-canvas arrange --layout flow
97
97
  pmx-canvas focus <node-id> --no-pan # Select/raise without moving the user's viewport
98
98
  pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary
99
99
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx --deps recharts --include-logs
100
+ pmx-canvas node list --type web-artifact --summary
101
+ pmx-canvas node list --type external-app --summary
100
102
  pmx-canvas pin --list
101
103
  pmx-canvas snapshot save --name "before-refactor"
102
104
  pmx-canvas code-graph
@@ -107,6 +109,7 @@ pmx-canvas spatial
107
109
 
108
110
  - `node add|list|get|update|remove` — manage nodes
109
111
  - `node schema` — inspect running-server create schemas and canonical examples, with `--summary`, `--field`, and `--component` filters
112
+ - Graph CLI fields accept both kebab-case flags and camelCase schema names, e.g. `--graph-type`/`--graphType`, `--x-key`/`--xKey`, and `--bar-color`/`--barColor`.
110
113
  - `edge add|list|remove` — manage edges
111
114
  - Search-based edge selectors must be specific enough to resolve exactly one node. Queries like
112
115
  `"DVT O3"` can be ambiguous; prefer the full visible title such as `"DVT O3 — GitOps"`.
@@ -119,6 +122,8 @@ pmx-canvas spatial
119
122
  - `group create|add|remove` — manage groups
120
123
  - `clear --yes` — destructive clear with explicit confirmation
121
124
  - `validate spec` — validate json-render specs and graph payloads without creating nodes
125
+ - `web-artifact build` — build bundled React/Tailwind HTML artifacts; use `--deps` for extra packages and `--include-logs` only when raw logs are useful
126
+ - `external-app add --kind excalidraw` — create the hosted Excalidraw preset; response includes `id` and `nodeId` aliases for the same canvas node
122
127
  - `serve status|stop` — inspect and stop daemonized servers started with `serve --daemon`
123
128
  - `code-graph`, `spatial` — analysis commands
124
129
 
@@ -128,10 +133,9 @@ Current caveat:
128
133
  through search later in the session. When you plan to curate an app-heavy comparison area,
129
134
  capture node IDs immediately after creation and verify membership with `node get --summary`,
130
135
  `layout --summary`, or the browser selection state instead of relying on search alone.
131
- - Some `mcp-app` creation flows are still awkward to address immediately after creation. If a
132
- diagram/app flow gives you a session-oriented result first, resolve the final canvas node from
133
- live layout or `node list --type mcp-app` before you try to group it, wire edges to it, or
134
- revisit it later.
136
+ - App-like nodes persist as `type: "mcp-app"` internally but serialized results include `kind`:
137
+ `web-artifact`, `external-app`, or `mcp-app`. Prefer `node list --type web-artifact` or
138
+ `node list --type external-app` when you need the operational subtype.
135
139
  - Generic `pmx-canvas node add --type mcp-app` is intentionally not supported because app nodes
136
140
  need app/session metadata. Use `pmx-canvas web-artifact build` for bundled React artifacts or
137
141
  `pmx-canvas external-app add --kind excalidraw` for the Excalidraw preset.
package/src/cli/agent.ts CHANGED
@@ -237,8 +237,9 @@ function parseStringListFlag(
237
237
  flags: Record<string, string | true>,
238
238
  name: string,
239
239
  hint: string,
240
+ ...aliases: string[]
240
241
  ): string[] | undefined {
241
- const raw = getStringFlag(flags, name);
242
+ const raw = getStringFlag(flags, name, ...aliases);
242
243
  if (raw === undefined) return undefined;
243
244
  const trimmed = raw.trim();
244
245
  if (!trimmed) {
@@ -295,6 +296,7 @@ function summarizeNodeResult(node: Record<string, unknown>): Record<string, unkn
295
296
  ...(node.ok !== undefined ? { ok: node.ok } : {}),
296
297
  id: node.id ?? null,
297
298
  type: node.type ?? null,
299
+ ...(typeof node.kind === 'string' ? { kind: node.kind } : {}),
298
300
  title: node.title ?? null,
299
301
  ...(typeof node.content === 'string' ? { contentPreview: truncateText(node.content) } : {}),
300
302
  ...(node.position !== undefined ? { position: node.position } : {}),
@@ -550,28 +552,39 @@ async function buildGraphRequestBody(
550
552
  const data = parseRecordArrayJson(rawData, hint);
551
553
 
552
554
  const body: Record<string, unknown> = {
553
- graphType: getStringFlag(flags, 'graph-type') ?? 'line',
555
+ graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line',
554
556
  data,
555
557
  };
556
558
  if (typeof flags.title === 'string') body.title = flags.title;
557
- if (typeof flags['x-key'] === 'string') body.xKey = flags['x-key'];
558
- if (typeof flags['y-key'] === 'string') body.yKey = flags['y-key'];
559
- if (typeof flags['z-key'] === 'string') body.zKey = flags['z-key'];
560
- if (typeof flags['name-key'] === 'string') body.nameKey = flags['name-key'];
561
- if (typeof flags['value-key'] === 'string') body.valueKey = flags['value-key'];
562
- if (typeof flags['axis-key'] === 'string') body.axisKey = flags['axis-key'];
559
+ const xKey = getStringFlag(flags, 'x-key', 'xKey');
560
+ const yKey = getStringFlag(flags, 'y-key', 'yKey');
561
+ const zKey = getStringFlag(flags, 'z-key', 'zKey');
562
+ const nameKey = getStringFlag(flags, 'name-key', 'nameKey');
563
+ const valueKey = getStringFlag(flags, 'value-key', 'valueKey');
564
+ const axisKey = getStringFlag(flags, 'axis-key', 'axisKey');
565
+ if (xKey) body.xKey = xKey;
566
+ if (yKey) body.yKey = yKey;
567
+ if (zKey) body.zKey = zKey;
568
+ if (nameKey) body.nameKey = nameKey;
569
+ if (valueKey) body.valueKey = valueKey;
570
+ if (axisKey) body.axisKey = axisKey;
563
571
  const metrics = parseStringListFlag(flags, 'metrics', 'Use a comma-separated list, e.g. --metrics north,south');
564
572
  const series = parseStringListFlag(flags, 'series', 'Use a comma-separated list, e.g. --series north,south');
565
573
  if (metrics) body.metrics = metrics;
566
574
  if (series) body.series = series;
567
- if (typeof flags['bar-key'] === 'string') body.barKey = flags['bar-key'];
568
- if (typeof flags['line-key'] === 'string') body.lineKey = flags['line-key'];
575
+ const barKey = getStringFlag(flags, 'bar-key', 'barKey');
576
+ const lineKey = getStringFlag(flags, 'line-key', 'lineKey');
577
+ if (barKey) body.barKey = barKey;
578
+ if (lineKey) body.lineKey = lineKey;
569
579
  if (flags.aggregate === 'sum' || flags.aggregate === 'count' || flags.aggregate === 'avg') {
570
580
  body.aggregate = flags.aggregate;
571
581
  }
572
- if (typeof flags.color === 'string') body.color = flags.color;
573
- if (typeof flags['bar-color'] === 'string') body.barColor = flags['bar-color'];
574
- if (typeof flags['line-color'] === 'string') body.lineColor = flags['line-color'];
582
+ const color = getStringFlag(flags, 'color');
583
+ const barColor = getStringFlag(flags, 'bar-color', 'barColor');
584
+ const lineColor = getStringFlag(flags, 'line-color', 'lineColor');
585
+ if (color) body.color = color;
586
+ if (barColor) body.barColor = barColor;
587
+ if (lineColor) body.lineColor = lineColor;
575
588
 
576
589
  const chartHeight = optionalPositiveFiniteFlag(flags, 'chart-height', 'Use a positive number, e.g. --chart-height 300');
577
590
  const x = optionalFiniteFlag(flags, 'x', 'Use a finite number, e.g. --x 500');
@@ -1035,7 +1048,7 @@ cmd('node list', 'List all nodes on the canvas', [
1035
1048
  let nodes = layout.nodes;
1036
1049
 
1037
1050
  if (flags.type && flags.type !== true) {
1038
- nodes = nodes.filter((n) => n.type === flags.type);
1051
+ nodes = nodes.filter((n) => n.type === flags.type || n.kind === flags.type);
1039
1052
  }
1040
1053
 
1041
1054
  if (flags.ids) {
@@ -1291,7 +1304,9 @@ cmd('status', 'Quick canvas summary', [
1291
1304
  const typeCounts: Record<string, number> = {};
1292
1305
  for (const n of layout.nodes) {
1293
1306
  const data = isRecord(n.data) ? n.data : {};
1294
- const t = n.type === 'mcp-app' && data.hostMode === 'hosted' && typeof data.path === 'string'
1307
+ const t = typeof n.kind === 'string'
1308
+ ? n.kind
1309
+ : n.type === 'mcp-app' && data.hostMode === 'hosted' && typeof data.path === 'string'
1295
1310
  ? 'web-artifact'
1296
1311
  : n.type as string;
1297
1312
  typeCounts[t] = (typeCounts[t] || 0) + 1;
@@ -1364,7 +1379,9 @@ cmd('external-app add', 'Create a hosted external app node', [
1364
1379
  });
1365
1380
 
1366
1381
  const result = await api('POST', '/api/canvas/diagram', body);
1367
- output(result);
1382
+ output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
1383
+ ? { id: (result as { nodeId?: unknown }).nodeId, ...result }
1384
+ : result);
1368
1385
  });
1369
1386
 
1370
1387
  // ── pin ──────────────────────────────────────────────────────
@@ -2021,8 +2038,13 @@ function showCommandHelp(name: string): void {
2021
2038
  console.log('\nSchema help:');
2022
2039
  console.log(' pmx-canvas node add --help --type webpage');
2023
2040
  console.log(' pmx-canvas node add --help --type json-render --component Table');
2041
+ console.log(' pmx-canvas node add --help --type graph');
2024
2042
  console.log(' pmx-canvas node add --help --type webpage --json');
2025
2043
  }
2044
+ if (name === 'node add' || name === 'validate spec') {
2045
+ console.log('\nGraph flags:');
2046
+ console.log(' Graph fields accept kebab-case CLI flags and camelCase schema names, e.g. --graph-type/--graphType and --x-key/--xKey');
2047
+ }
2026
2048
  if (name === 'node schema') {
2027
2049
  console.log('\nFilters:');
2028
2050
  console.log(' --summary Show compact schema summaries');
@@ -166,7 +166,8 @@ function ensureMcpAppNode(data: Record<string, unknown>): void {
166
166
 
167
167
  function ensureExtAppNode(data: Record<string, unknown>): void {
168
168
  const toolCallId = data.toolCallId as string;
169
- const id = `ext-app-${toolCallId}`;
169
+ const eventNodeId = typeof data.nodeId === 'string' && data.nodeId.length > 0 ? data.nodeId : null;
170
+ const id = eventNodeId ?? (toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`);
170
171
  const existing = nodes.value.get(id);
171
172
  if (existing) {
172
173
  updateNodeData(id, data);
@@ -232,8 +233,10 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
232
233
  }
233
234
 
234
235
  function findExtAppNodeId(toolCallId: string): string | null {
235
- const directId = `ext-app-${toolCallId}`;
236
+ const directId = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
236
237
  if (nodes.value.has(directId)) return directId;
238
+ const legacyDirectId = `ext-app-${toolCallId}`;
239
+ if (legacyDirectId !== directId && nodes.value.has(legacyDirectId)) return legacyDirectId;
237
240
  for (const [nodeId, node] of nodes.value.entries()) {
238
241
  if (
239
242
  node.type === 'mcp-app' &&
@@ -246,6 +249,13 @@ function findExtAppNodeId(toolCallId: string): string | null {
246
249
  return null;
247
250
  }
248
251
 
252
+ function findExtAppEventNodeId(data: Record<string, unknown>): string | null {
253
+ const eventNodeId = typeof data.nodeId === 'string' && data.nodeId.length > 0 ? data.nodeId : null;
254
+ if (eventNodeId && nodes.value.has(eventNodeId)) return eventNodeId;
255
+ if (typeof data.toolCallId !== 'string' || !data.toolCallId) return null;
256
+ return findExtAppNodeId(data.toolCallId);
257
+ }
258
+
249
259
  function findOnlyPendingExtAppNodeId(serverName: unknown, toolName: unknown): string | null {
250
260
  if (typeof serverName !== 'string' || !serverName) return null;
251
261
  if (typeof toolName !== 'string' || !toolName) return null;
@@ -542,7 +552,7 @@ function handleExtAppOpen(data: Record<string, unknown>): void {
542
552
  function handleExtAppUpdate(data: Record<string, unknown>): void {
543
553
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
544
554
  const id =
545
- findExtAppNodeId(data.toolCallId) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
555
+ findExtAppEventNodeId(data) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
546
556
  if (!id) return;
547
557
  if (nodes.value.has(id)) {
548
558
  updateNodeData(id, { html: data.html });
@@ -552,7 +562,7 @@ function handleExtAppUpdate(data: Record<string, unknown>): void {
552
562
  function handleExtAppResult(data: Record<string, unknown>): void {
553
563
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
554
564
  const id =
555
- findExtAppNodeId(data.toolCallId) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
565
+ findExtAppEventNodeId(data) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
556
566
  if (!id) return;
557
567
  if (nodes.value.has(id)) {
558
568
  if (data.success === false) {
@@ -103,15 +103,17 @@ export const tooltipStyle = {
103
103
  export function CartesianChart({
104
104
  props,
105
105
  children,
106
+ className,
106
107
  }: {
107
108
  props: CartesianChartProps;
108
109
  children: (data: Record<string, unknown>[]) => ReactNode;
110
+ className?: string;
109
111
  }) {
110
112
  const chartData = processChartData(props.data ?? [], props.xKey, props.yKey, props.aggregate);
111
113
  const h = props.height ?? 300;
112
114
 
113
115
  return (
114
- <div className="pmx-chart">
116
+ <div className={`pmx-chart${className ? ` ${className}` : ''}`}>
115
117
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
116
118
  <ResponsiveContainer width="100%" height={h}>
117
119
  {children(chartData)}
@@ -123,7 +125,7 @@ export function CartesianChart({
123
125
  function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>) {
124
126
  const stroke = props.color ?? CHART_COLORS[0];
125
127
  return (
126
- <CartesianChart props={props}>
128
+ <CartesianChart props={props} className="pmx-chart--line">
127
129
  {(data) => (
128
130
  <RechartsLineChart data={data}>
129
131
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
@@ -147,7 +149,7 @@ function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>) {
147
149
  function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>) {
148
150
  const fill = props.color ?? CHART_COLORS[0];
149
151
  return (
150
- <CartesianChart props={props}>
152
+ <CartesianChart props={props} className="pmx-chart--bar">
151
153
  {(data) => (
152
154
  <RechartsBarChart data={data}>
153
155
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
@@ -166,7 +168,7 @@ function ChartPieChart({ props }: BaseComponentProps<PieChartProps>) {
166
168
  const h = props.height ?? 300;
167
169
 
168
170
  return (
169
- <div className="pmx-chart">
171
+ <div className="pmx-chart pmx-chart--pie">
170
172
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
171
173
  <ResponsiveContainer width="100%" height={h}>
172
174
  <RechartsPieChart>
@@ -45,7 +45,7 @@ function ChartAreaChart({ props }: BaseComponentProps<AreaChartProps>) {
45
45
  const stroke = props.color ?? CHART_COLORS[0];
46
46
  const gradientId = `pmx-area-${props.yKey ?? 'value'}`;
47
47
  return (
48
- <CartesianChart props={props}>
48
+ <CartesianChart props={props} className="pmx-chart--area">
49
49
  {(data) => (
50
50
  <RechartsAreaChart data={data}>
51
51
  <defs>
@@ -88,7 +88,7 @@ function ChartScatterChart({ props }: BaseComponentProps<ScatterChartProps>) {
88
88
  const h = props.height ?? 300;
89
89
 
90
90
  return (
91
- <div className="pmx-chart">
91
+ <div className="pmx-chart pmx-chart--scatter">
92
92
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
93
93
  <ResponsiveContainer width="100%" height={h}>
94
94
  <RechartsScatterChart>
@@ -118,7 +118,7 @@ function ChartRadarChart({ props }: BaseComponentProps<RadarChartProps>) {
118
118
  const h = props.height ?? 320;
119
119
 
120
120
  return (
121
- <div className="pmx-chart">
121
+ <div className="pmx-chart pmx-chart--radar">
122
122
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
123
123
  <ResponsiveContainer width="100%" height={h}>
124
124
  <RechartsRadarChart data={data} outerRadius="75%">
@@ -163,7 +163,7 @@ function ChartStackedBarChart({ props }: BaseComponentProps<StackedBarChartProps
163
163
  const h = props.height ?? 300;
164
164
 
165
165
  return (
166
- <div className="pmx-chart">
166
+ <div className="pmx-chart pmx-chart--stacked-bar">
167
167
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
168
168
  <ResponsiveContainer width="100%" height={h}>
169
169
  <RechartsBarChart data={chartData}>
@@ -234,7 +234,7 @@ function ChartComposedChart({ props }: BaseComponentProps<ComposedChartProps>) {
234
234
  const h = props.height ?? 300;
235
235
 
236
236
  return (
237
- <div className="pmx-chart">
237
+ <div className="pmx-chart pmx-chart--composed">
238
238
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
239
239
  <ResponsiveContainer width="100%" height={h}>
240
240
  <RechartsComposedChart data={data}>
@@ -147,9 +147,23 @@ button {
147
147
 
148
148
  .pmx-chart {
149
149
  width: 100%;
150
+ min-width: 280px;
151
+ overflow-x: auto;
150
152
  padding: 0.5rem 0;
151
153
  }
152
154
 
155
+ .pmx-chart--line,
156
+ .pmx-chart--area,
157
+ .pmx-chart--scatter,
158
+ .pmx-chart--stacked-bar,
159
+ .pmx-chart--composed {
160
+ min-width: 320px;
161
+ }
162
+
163
+ .pmx-chart--radar {
164
+ min-width: 360px;
165
+ }
166
+
153
167
  .pmx-chart__title {
154
168
  font-size: 0.875rem;
155
169
  font-weight: 600;
package/src/mcp/server.ts CHANGED
@@ -279,10 +279,18 @@ export async function startMcpServer(): Promise<void> {
279
279
  data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
280
280
  xKey: z.string().optional().describe('X-axis key for line/bar graphs'),
281
281
  yKey: z.string().optional().describe('Y-axis key for line/bar graphs'),
282
+ zKey: z.string().optional().describe('Optional bubble-size key for scatter charts'),
282
283
  nameKey: z.string().optional().describe('Slice name key for pie graphs'),
283
284
  valueKey: z.string().optional().describe('Slice value key for pie graphs'),
285
+ axisKey: z.string().optional().describe('Category key for radar charts'),
286
+ metrics: z.array(z.string()).optional().describe('Series keys to plot as radar polygons'),
287
+ series: z.array(z.string()).optional().describe('Series keys for stacked-bar segments'),
288
+ barKey: z.string().optional().describe('Bar series key for composed charts'),
289
+ lineKey: z.string().optional().describe('Line series key for composed charts'),
284
290
  aggregate: z.enum(['sum', 'count', 'avg']).optional().describe('Optional aggregation for repeated keys'),
285
291
  color: z.string().optional().describe('Optional graph color'),
292
+ barColor: z.string().optional().describe('Optional bar color for composed charts'),
293
+ lineColor: z.string().optional().describe('Optional line color for composed charts'),
286
294
  height: z.number().optional().describe('Optional graph content height'),
287
295
  },
288
296
  async (input) => {
@@ -300,10 +308,18 @@ export async function startMcpServer(): Promise<void> {
300
308
  data: input.data ?? [],
301
309
  ...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
302
310
  ...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
311
+ ...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
303
312
  ...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
304
313
  ...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
314
+ ...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
315
+ ...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
316
+ ...(Array.isArray(input.series) ? { series: input.series } : {}),
317
+ ...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
318
+ ...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
305
319
  ...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
306
320
  ...(typeof input.color === 'string' ? { color: input.color } : {}),
321
+ ...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
322
+ ...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
307
323
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
308
324
  },
309
325
  });
@@ -1210,12 +1210,20 @@ export async function executeCanvasBatch(
1210
1210
  ...(typeof args.title === 'string' ? { title: args.title } : {}),
1211
1211
  ...(typeof args.xKey === 'string' ? { xKey: args.xKey } : {}),
1212
1212
  ...(typeof args.yKey === 'string' ? { yKey: args.yKey } : {}),
1213
+ ...(typeof args.zKey === 'string' ? { zKey: args.zKey } : {}),
1213
1214
  ...(typeof args.nameKey === 'string' ? { nameKey: args.nameKey } : {}),
1214
1215
  ...(typeof args.valueKey === 'string' ? { valueKey: args.valueKey } : {}),
1216
+ ...(typeof args.axisKey === 'string' ? { axisKey: args.axisKey } : {}),
1217
+ ...(Array.isArray(args.metrics) ? { metrics: args.metrics.filter((m): m is string => typeof m === 'string') } : {}),
1218
+ ...(Array.isArray(args.series) ? { series: args.series.filter((s): s is string => typeof s === 'string') } : {}),
1219
+ ...(typeof args.barKey === 'string' ? { barKey: args.barKey } : {}),
1220
+ ...(typeof args.lineKey === 'string' ? { lineKey: args.lineKey } : {}),
1215
1221
  ...(args.aggregate === 'sum' || args.aggregate === 'count' || args.aggregate === 'avg'
1216
1222
  ? { aggregate: args.aggregate }
1217
1223
  : {}),
1218
1224
  ...(typeof args.color === 'string' ? { color: args.color } : {}),
1225
+ ...(typeof args.barColor === 'string' ? { barColor: args.barColor } : {}),
1226
+ ...(typeof args.lineColor === 'string' ? { lineColor: args.lineColor } : {}),
1219
1227
  ...(typeof args.height === 'number' ? { height: args.height } : {}),
1220
1228
  ...(typeof args.x === 'number' ? { x: args.x } : {}),
1221
1229
  ...(typeof args.y === 'number' ? { y: args.y } : {}),
@@ -282,26 +282,27 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
282
282
  type: '"line" | "bar" | "pie" | "area" | "scatter" | "radar" | "stacked-bar" | "composed"',
283
283
  required: true,
284
284
  description: 'Chart type. Aliases like "stack" and "combo" are normalized server-side.',
285
+ aliases: ['graph-type'],
285
286
  },
286
- { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.', aliases: ['data-json'] },
287
+ { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset. The CLI also accepts piped JSON via --stdin.', aliases: ['data-json', 'data-file'] },
287
288
  { name: 'title', type: 'string', required: false, description: 'Optional graph title.' },
288
- { name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.' },
289
- { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.' },
290
- { name: 'zKey', type: 'string', required: false, description: 'Optional bubble-size key for scatter charts.' },
291
- { name: 'nameKey', type: 'string', required: false, description: 'Slice name key for pie graphs.' },
292
- { name: 'valueKey', type: 'string', required: false, description: 'Slice value key for pie graphs.' },
293
- { name: 'axisKey', type: 'string', required: false, description: 'Category key for radar charts.' },
289
+ { name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.', aliases: ['x-key'] },
290
+ { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.', aliases: ['y-key'] },
291
+ { name: 'zKey', type: 'string', required: false, description: 'Optional bubble-size key for scatter charts.', aliases: ['z-key'] },
292
+ { name: 'nameKey', type: 'string', required: false, description: 'Slice name key for pie graphs.', aliases: ['name-key'] },
293
+ { name: 'valueKey', type: 'string', required: false, description: 'Slice value key for pie graphs.', aliases: ['value-key'] },
294
+ { name: 'axisKey', type: 'string', required: false, description: 'Category key for radar charts.', aliases: ['axis-key'] },
294
295
  { name: 'metrics', type: 'string[]', required: false, description: 'Series keys to plot as radar polygons. Defaults to non-axis numeric columns.' },
295
296
  { name: 'series', type: 'string[]', required: false, description: 'Series keys for stacked-bar segments. Defaults to non-x numeric columns.' },
296
- { name: 'barKey', type: 'string', required: false, description: 'Bar series key for composed charts.' },
297
- { name: 'lineKey', type: 'string', required: false, description: 'Line series key for composed charts.' },
297
+ { name: 'barKey', type: 'string', required: false, description: 'Bar series key for composed charts.', aliases: ['bar-key'] },
298
+ { name: 'lineKey', type: 'string', required: false, description: 'Line series key for composed charts.', aliases: ['line-key'] },
298
299
  { name: 'aggregate', type: '"sum" | "count" | "avg"', required: false, description: 'Optional aggregation for repeated x-axis values in line, bar, area, and stacked-bar charts.' },
299
300
  { name: 'color', type: 'string', required: false, description: 'Optional series color for line, bar, area, and scatter charts.' },
300
- { name: 'barColor', type: 'string', required: false, description: 'Optional bar color for composed charts.' },
301
- { name: 'lineColor', type: 'string', required: false, description: 'Optional line color for composed charts.' },
302
- { name: 'height', type: 'number', required: false, description: 'Optional chart content height.' },
301
+ { name: 'barColor', type: 'string', required: false, description: 'Optional bar color for composed charts.', aliases: ['bar-color'] },
302
+ { name: 'lineColor', type: 'string', required: false, description: 'Optional line color for composed charts.', aliases: ['line-color'] },
303
+ { name: 'height', type: 'number', required: false, description: 'Optional chart content height.', aliases: ['chart-height'] },
303
304
  { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
304
- { name: 'nodeHeight', type: 'number', required: false, description: 'Optional node height.' },
305
+ { name: 'nodeHeight', type: 'number', required: false, description: 'Optional node height (canvas frame). Distinct from `height`, which sets only the chart content height inside the node.', aliases: ['node-height'] },
305
306
  ],
306
307
  example: {
307
308
  title: 'Deploy Trend',
@@ -326,7 +327,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
326
327
  endpoint: '/api/canvas/web-artifact',
327
328
  fields: [
328
329
  { name: 'title', type: 'string', required: true, description: 'Artifact title used for default paths.' },
329
- { name: 'appTsx', type: 'string', required: true, description: 'Contents for src/App.tsx.', aliases: ['stdin', 'app-file', 'app-tsx'] },
330
+ { name: 'appTsx', type: 'string', required: true, description: 'Contents for src/App.tsx. The CLI also accepts piped contents via --stdin.', aliases: ['app-file', 'app-tsx'] },
330
331
  { name: 'indexCss', type: 'string', required: false, description: 'Optional src/index.css contents.', aliases: ['index-css-file', 'index-css'] },
331
332
  { name: 'mainTsx', type: 'string', required: false, description: 'Optional src/main.tsx contents.', aliases: ['main-file', 'main-tsx'] },
332
333
  { name: 'indexHtml', type: 'string', required: false, description: 'Optional index.html contents.', aliases: ['index-html-file', 'index-html'] },
@@ -6,6 +6,7 @@ import {
6
6
  } from './canvas-provenance.js';
7
7
 
8
8
  export interface SerializedCanvasNode extends CanvasNodeState {
9
+ kind: string;
9
10
  title: string | null;
10
11
  content: string | null;
11
12
  path: string | null;
@@ -26,6 +27,21 @@ function pickProvenance(value: unknown): CanvasNodeProvenance | null {
26
27
  return value as CanvasNodeProvenance;
27
28
  }
28
29
 
30
+ function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string {
31
+ if (node.type !== 'mcp-app') return node.type;
32
+ // Authoritative discriminator added in v0.1.4. New web-artifacts always set
33
+ // it; matching here first means a future URL-only artifact (no `data.path`)
34
+ // still classifies correctly without falling through to the legacy heuristic.
35
+ if (data.viewerType === 'web-artifact') return 'web-artifact';
36
+ if (data.mode === 'ext-app') return 'external-app';
37
+ // Transitional fallback for canvas state.json files persisted before v0.1.4
38
+ // introduced `viewerType`. Web-artifacts written by older versions always
39
+ // stored a `path` to the bundled HTML file, so this heuristic is safe for
40
+ // existing data. Remove in v0.2.x once a one-shot migration runs at boot.
41
+ if (data.hostMode === 'hosted' && typeof data.path === 'string') return 'web-artifact';
42
+ return 'mcp-app';
43
+ }
44
+
29
45
  export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
30
46
  return pickString(node.data.title)
31
47
  ?? (node.type === 'webpage' ? pickString(node.data.pageTitle) : null)
@@ -47,6 +63,7 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
47
63
  return {
48
64
  ...node,
49
65
  data,
66
+ kind: getCanvasNodeKind(node, data),
50
67
  title: getCanvasNodeTitle(node),
51
68
  content: getCanvasNodeContent(node),
52
69
  path: pickString(data.path),
@@ -77,7 +94,8 @@ export function buildCanvasSummary(): CanvasSummary {
77
94
 
78
95
  const typeCounts: Record<string, number> = {};
79
96
  for (const n of layout.nodes) {
80
- typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
97
+ const kind = getCanvasNodeKind(n, normalizeCanvasNodeData(n.type, n.data));
98
+ typeCounts[kind] = (typeCounts[kind] ?? 0) + 1;
81
99
  }
82
100
 
83
101
  const pinnedTitles = layout.nodes
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Resolve the canvas node ID for a given ext-app `toolCallId`.
3
+ *
4
+ * v0.1.4 fixed a long-standing `ext-app-ext-app-…` double-prefix bug where
5
+ * both `nodeId` and `toolCallId` carried the `ext-app-` prefix. This helper
6
+ * encodes the lookup contract so it doesn't drift between the
7
+ * `PmxCanvas` SDK class and the HTTP server.
8
+ *
9
+ * Resolution order:
10
+ * 1. The direct prefixed form (`ext-app-<toolCallId>` if not already
11
+ * prefixed, otherwise `toolCallId` as-is).
12
+ * 2. The legacy `ext-app-ext-app-…` form, for canvases persisted before
13
+ * v0.1.4 and still on disk. Remove this fallback in v0.2.x.
14
+ * 3. A scan of the layout for any `mcp-app` ext-app node carrying that
15
+ * `toolCallId` in its data.
16
+ */
17
+ import type { CanvasNodeState } from './canvas-state.js';
18
+
19
+ export interface ExtAppLookupSource {
20
+ getNode(id: string): CanvasNodeState | undefined;
21
+ listNodes(): readonly CanvasNodeState[];
22
+ }
23
+
24
+ export function findCanvasExtAppNodeId(
25
+ toolCallId: string,
26
+ source: ExtAppLookupSource,
27
+ ): string | null {
28
+ const directId = toolCallId.startsWith('ext-app-')
29
+ ? toolCallId
30
+ : `ext-app-${toolCallId}`;
31
+ if (source.getNode(directId)) return directId;
32
+
33
+ const legacyDirectId = `ext-app-${toolCallId}`;
34
+ if (legacyDirectId !== directId && source.getNode(legacyDirectId)) {
35
+ return legacyDirectId;
36
+ }
37
+
38
+ for (const node of source.listNodes()) {
39
+ if (
40
+ node.type === 'mcp-app' &&
41
+ node.data.mode === 'ext-app' &&
42
+ node.data.toolCallId === toolCallId
43
+ ) {
44
+ return node.id;
45
+ }
46
+ }
47
+
48
+ return null;
49
+ }