pmx-canvas 0.1.8 → 0.1.10

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.
@@ -48,6 +48,7 @@ export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?
48
48
  export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
49
49
  export declare function normalizeGraphType(value: string): GraphChartType;
50
50
  export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
51
+ export declare function buildGraphConfig(input: GraphNodeInput): Record<string, unknown>;
51
52
  export declare function createJsonRenderNodeData(nodeId: string, title: string, spec: JsonRenderSpec, extra?: Record<string, unknown>): Record<string, unknown>;
52
53
  export declare function buildJsonRenderViewerHtml(options: {
53
54
  title: string;
@@ -2,6 +2,38 @@ import { type CanvasEdge, type CanvasNodeState, type CanvasNodeUpdate, type Canv
2
2
  import { type GraphNodeInput, type JsonRenderNodeInput, type JsonRenderSpec } from '../json-render/server.js';
3
3
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
4
4
  export type CanvasPinMode = 'set' | 'add' | 'remove';
5
+ export interface CanvasFitViewOptions {
6
+ width?: number;
7
+ height?: number;
8
+ padding?: number;
9
+ maxScale?: number;
10
+ nodeIds?: string[];
11
+ }
12
+ export interface CanvasFitViewResult {
13
+ ok: true;
14
+ viewport: {
15
+ x: number;
16
+ y: number;
17
+ scale: number;
18
+ };
19
+ nodeCount: number;
20
+ bounds: {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ } | null;
26
+ }
27
+ export interface CanvasGraphNodeUpdateInput extends Partial<GraphNodeInput> {
28
+ spec?: unknown;
29
+ type?: string;
30
+ }
31
+ export interface CanvasStructuredNodeUpdateInput extends Omit<CanvasGraphNodeUpdateInput, 'data'> {
32
+ content?: unknown;
33
+ data?: unknown;
34
+ arrangeLocked?: unknown;
35
+ chartHeight?: unknown;
36
+ }
5
37
  interface CanvasAddNodeInput {
6
38
  type: CanvasNodeState['type'];
7
39
  title?: string;
@@ -34,6 +66,22 @@ interface CanvasNodeLookupInput {
34
66
  id?: string;
35
67
  search?: string;
36
68
  }
69
+ export declare function hasStructuredNodeUpdateFields(input: Record<string, unknown>): boolean;
70
+ export declare function buildStructuredNodeUpdate(node: CanvasNodeState, input: CanvasStructuredNodeUpdateInput): {
71
+ data: Record<string, unknown>;
72
+ };
73
+ export declare function buildJsonRenderNodeUpdate(node: CanvasNodeState, input: {
74
+ title?: string;
75
+ spec: unknown;
76
+ }): {
77
+ data: Record<string, unknown>;
78
+ spec: JsonRenderSpec;
79
+ };
80
+ export declare function buildGraphNodeUpdate(node: CanvasNodeState, input: CanvasGraphNodeUpdateInput): {
81
+ data: Record<string, unknown>;
82
+ spec: JsonRenderSpec;
83
+ graphConfig: Record<string, unknown>;
84
+ };
37
85
  export declare function primeCanvasRuntimeBackends(options?: {
38
86
  forceRehydrateExtApps?: boolean;
39
87
  }): {
@@ -144,6 +192,7 @@ export declare function createCanvasGraphNode(input: GraphNodeInput): {
144
192
  spec: JsonRenderSpec;
145
193
  node: CanvasNodeState;
146
194
  };
195
+ export declare function fitCanvasView(options?: CanvasFitViewOptions): CanvasFitViewResult;
147
196
  export declare function executeCanvasBatch(operations: CanvasBatchOperation[]): Promise<{
148
197
  ok: boolean;
149
198
  results: Array<Record<string, unknown>>;
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
2
2
  import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
3
3
  import { searchNodes } from './spatial-analysis.js';
4
4
  import { diffLayouts } from './mutation-history.js';
5
+ import { fitCanvasView } from './canvas-operations.js';
5
6
  import { type WebArtifactBuildInput, type WebArtifactCanvasBuildResult } from './web-artifacts.js';
6
7
  import { type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
7
8
  import { type DiagramPresetOpenInput } from './diagram-presets.js';
@@ -48,7 +49,7 @@ export declare class PmxCanvas extends EventEmitter {
48
49
  id: string;
49
50
  error?: string;
50
51
  }>;
51
- updateNode(id: string, patch: Partial<CanvasNodeState>): void;
52
+ updateNode(id: string, patch: Partial<CanvasNodeState> & Record<string, unknown>): void;
52
53
  removeNode(id: string): void;
53
54
  addEdge(input: {
54
55
  from?: string;
@@ -89,6 +90,13 @@ export declare class PmxCanvas extends EventEmitter {
89
90
  focused: string;
90
91
  panned: boolean;
91
92
  } | null;
93
+ fitView(options?: {
94
+ width?: number;
95
+ height?: number;
96
+ padding?: number;
97
+ maxScale?: number;
98
+ nodeIds?: string[];
99
+ }): ReturnType<typeof fitCanvasView>;
92
100
  getLayout(): CanvasLayout;
93
101
  getNode(id: string): CanvasNodeState | undefined;
94
102
  search(query: string): ReturnType<typeof searchNodes>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
@@ -143,6 +143,8 @@ pmx-canvas search "auth"
143
143
  pmx-canvas open
144
144
  pmx-canvas arrange --layout flow
145
145
  pmx-canvas focus <node-id> --no-pan # Select/raise without moving the user's viewport
146
+ pmx-canvas fit --width 1440 --height 900 # Fit the whole board for screenshots/review
147
+ pmx-canvas node update <node-id> --spec-file ./dashboard.json
146
148
  pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary
147
149
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx --deps recharts --include-logs
148
150
  pmx-canvas node list --type web-artifact --summary
@@ -167,6 +169,7 @@ pmx-canvas spatial
167
169
  `"DVT O3"` can be ambiguous; prefer the full visible title such as `"DVT O3 — GitOps"`.
168
170
  - `search`, `layout`, `status`, `arrange`, `focus` — inspect and navigate the canvas. Prefer
169
171
  `focus --no-pan` when you only need to select/raise a node without hijacking the human's camera.
172
+ - `fit [id ...]` — set the server viewport to fit the whole canvas or selected nodes before screenshots or whole-board review
170
173
  - `open` — open the current workbench in the browser
171
174
  - `pin --list|--clear|<ids...>` — manage context pins
172
175
  - `undo`, `redo`, `history` — time travel
@@ -279,7 +282,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
279
282
 
280
283
  **`canvas_update_node`** — Update an existing node
281
284
  - `id` (required): node to update
282
- - Any of: `title`, `content`, `color`, `x`, `y`, `width`, `height`, `collapsed`, `metadata`
285
+ - Any of: `title`, `content`, `x`, `y`, `width`, `height`, `collapsed`, `arrangeLocked`, `data`
286
+ - For `json-render`, pass `spec` to update the rendered spec in place while preserving node ID, edges, pins, and position
287
+ - For `graph`, pass graph fields such as `graphType`, `data`, `xKey`, `yKey`, `color`, and `chartHeight` to rebuild the chart in place; use `height`/`nodeHeight` for frame geometry and `chartHeight` for chart content in CLI flows
283
288
  - Use to update status nodes as work progresses
284
289
 
285
290
  **`canvas_remove_node`** — Remove a node and all its connected edges
@@ -357,6 +362,10 @@ ID extraction for mixed tool responses:
357
362
  - Returns the normalized json-render spec the server would accept
358
363
  - Use this when you want a dry run before creating a `json-render` or `graph` node
359
364
 
365
+ **`canvas_fit_view`** — Fit viewport to all nodes or selected nodes
366
+ - Optional: `width`, `height`, `padding`, `maxScale`, `nodeIds`
367
+ - Use before screenshot workflows or whole-board review so the server viewport matches the intended camera
368
+
360
369
  **Batch graph creation**
361
370
  - Use `graph.add` inside `canvas_batch` / `pmx-canvas batch` when you need a graph node as part of
362
371
  a larger one-shot build.
@@ -512,6 +521,8 @@ tools below operate on the live canvas state.
512
521
  - Required: exactly one of `expression` (single JS expression) or `script` (multi-statement body)
513
522
  - `script` is wrapped in an async IIFE, so top-level `await` works inside script bodies
514
523
  - Useful for asserting DOM state after a sequence of canvas mutations
524
+ - Do not use `fetch()` inside `canvas_evaluate` to call PMX HTTP APIs; WebView security/CORS
525
+ restrictions can block those requests. Use the matching MCP tools instead.
515
526
  - Example: read the count of rendered `.canvas-node` elements:
516
527
 
517
528
  ```typescript
@@ -666,6 +677,7 @@ All POST/PATCH endpoints accept `Content-Type: application/json`. Default base U
666
677
  | POST | `/api/canvas/group/ungroup` | Ungroup |
667
678
  | POST | `/api/canvas/arrange` | Auto-arrange |
668
679
  | POST | `/api/canvas/focus` | Center viewport on node |
680
+ | POST | `/api/canvas/fit` | Fit viewport to canvas bounds or selected nodes |
669
681
  | POST | `/api/canvas/clear` | Clear canvas |
670
682
  | POST | `/api/canvas/update` | Batch update positions |
671
683
  | GET | `/api/canvas/spatial-context` | Spatial clusters and reading order |
@@ -144,7 +144,7 @@ for _ in $(seq 1 120); do
144
144
 
145
145
  NODE_COUNT="$(
146
146
  curl -fsS "http://127.0.0.1:${PORT}/api/canvas/state" \
147
- | "${BUN_BIN}" -e 'let raw="";for await (const chunk of Bun.stdin.stream()){raw+=chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);} const state=JSON.parse(raw); console.log(state.nodes.length);'
147
+ | python3 -c 'import json, sys; print(len(json.load(sys.stdin).get("nodes", [])))'
148
148
  )"
149
149
 
150
150
  if [[ "${NODE_COUNT}" -ge 18 ]]; then
@@ -109,6 +109,17 @@ function ensure_bundle_dependencies() {
109
109
  fi
110
110
  done
111
111
 
112
+ # package.json lists the deps, but node_modules may not be in sync (e.g. fresh
113
+ # CI checkout where node_modules is gitignored). Run a regular install so the
114
+ # local binaries actually exist before we hand off to Parcel. The build
115
+ # allowlist is already persisted in package.json's pnpm.onlyBuiltDependencies
116
+ # by the original `pnpm add` step, so plain `install` is enough here.
117
+ if [ ! -x "./node_modules/.bin/parcel" ]; then
118
+ echo "📦 Syncing bundling dependencies into node_modules..."
119
+ run_pnpm_quiet install
120
+ return 0
121
+ fi
122
+
112
123
  echo "✅ Reusing existing bundling dependencies"
113
124
  }
114
125
 
package/src/cli/agent.ts CHANGED
@@ -241,6 +241,14 @@ function optionalPositiveFiniteFlagWithAliases(
241
241
  return undefined;
242
242
  }
243
243
 
244
+ function optionalBooleanFlag(flags: Record<string, string | true>, name: string, hint: string): boolean | undefined {
245
+ const val = flags[name];
246
+ if (val === undefined) return undefined;
247
+ if (val === true || val === 'true') return true;
248
+ if (val === 'false') return false;
249
+ die(`Invalid value for --${name}: ${String(val)}`, hint);
250
+ }
251
+
244
252
  function isRecord(value: unknown): value is Record<string, unknown> {
245
253
  return !!value && typeof value === 'object' && !Array.isArray(value);
246
254
  }
@@ -503,6 +511,41 @@ async function readTextInput(
503
511
  die(options.requiredMessage, options.hint);
504
512
  }
505
513
 
514
+ async function readOptionalTextInput(
515
+ flags: Record<string, string | true>,
516
+ options: {
517
+ fileFlags?: string[];
518
+ valueFlags?: string[];
519
+ allowStdin?: boolean;
520
+ label: string;
521
+ hint: string;
522
+ },
523
+ ): Promise<string | undefined> {
524
+ for (const name of options.fileFlags ?? []) {
525
+ const path = getStringFlag(flags, name);
526
+ if (!path) continue;
527
+ try {
528
+ return readFileSync(path, 'utf-8');
529
+ } catch (error) {
530
+ die(
531
+ `Unable to read --${name}: ${error instanceof Error ? error.message : String(error)}`,
532
+ options.hint,
533
+ );
534
+ }
535
+ }
536
+
537
+ for (const name of options.valueFlags ?? []) {
538
+ const value = getStringFlag(flags, name);
539
+ if (value !== undefined) return value;
540
+ }
541
+
542
+ if (options.allowStdin && flags.stdin) {
543
+ return await readStdin();
544
+ }
545
+
546
+ return undefined;
547
+ }
548
+
506
549
  function applyCommonGeometryFlags(
507
550
  body: Record<string, unknown>,
508
551
  flags: Record<string, string | true>,
@@ -518,6 +561,27 @@ function applyCommonGeometryFlags(
518
561
  if (height !== undefined) body.height = height;
519
562
  }
520
563
 
564
+ async function applyStructuredNodeUpdateFlags(
565
+ body: Record<string, unknown>,
566
+ flags: Record<string, string | true>,
567
+ ): Promise<void> {
568
+ const specRaw = await readOptionalTextInput(flags, {
569
+ fileFlags: ['spec-file'],
570
+ valueFlags: ['spec-json'],
571
+ allowStdin: false,
572
+ label: 'JSON spec',
573
+ hint: 'Use: pmx-canvas node update <node-id> --spec-file ./new-spec.json',
574
+ });
575
+ if (specRaw !== undefined) {
576
+ body.spec = parseJsonValue(specRaw, 'JSON spec', 'Use: pmx-canvas node update <node-id> --spec-file ./new-spec.json');
577
+ }
578
+
579
+ const graphPatch = await buildGraphRequestBody(flags, { requireData: false, allowStdin: false });
580
+ for (const [key, value] of Object.entries(graphPatch)) {
581
+ body[key === 'height' ? 'chartHeight' : key] = value;
582
+ }
583
+ }
584
+
521
585
  async function buildJsonRenderRequestBody(
522
586
  flags: Record<string, string | true>,
523
587
  ): Promise<Record<string, unknown>> {
@@ -547,23 +611,30 @@ async function buildJsonRenderRequestBody(
547
611
 
548
612
  async function buildGraphRequestBody(
549
613
  flags: Record<string, string | true>,
614
+ options: { requireData?: boolean; allowStdin?: boolean } = {},
550
615
  ): Promise<Record<string, unknown>> {
616
+ const requireData = options.requireData !== false;
617
+ const allowStdin = options.allowStdin !== false;
551
618
  const hint =
552
619
  'Use: pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value';
553
- const rawData = await readTextInput(flags, {
620
+
621
+ const body: Record<string, unknown> = {
622
+ ...(requireData ? { graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line' } : {}),
623
+ };
624
+ const rawData = await readOptionalTextInput(flags, {
554
625
  fileFlags: ['data-file'],
555
626
  valueFlags: ['data-json', 'data'],
556
- allowStdin: true,
627
+ allowStdin,
557
628
  label: 'graph JSON dataset',
558
629
  hint,
559
- requiredMessage: 'Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.',
560
630
  });
561
- const data = parseRecordArrayJson(rawData, hint);
562
-
563
- const body: Record<string, unknown> = {
564
- graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line',
565
- data,
566
- };
631
+ if (rawData !== undefined) {
632
+ body.data = parseRecordArrayJson(rawData, hint);
633
+ } else if (requireData) {
634
+ die('Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.', hint);
635
+ }
636
+ const graphType = getStringFlag(flags, 'graph-type', 'graphType');
637
+ if (graphType) body.graphType = graphType;
567
638
  if (typeof flags.title === 'string') body.title = flags.title;
568
639
  const xKey = getStringFlag(flags, 'x-key', 'xKey');
569
640
  const yKey = getStringFlag(flags, 'y-key', 'yKey');
@@ -1151,6 +1222,9 @@ cmd('node update', 'Update a node by ID', [
1151
1222
  'pmx-canvas node update <node-id> --content "Updated content"',
1152
1223
  'pmx-canvas node update <node-id> --title "Moved" --x 500 --y 300',
1153
1224
  'pmx-canvas node update <node-id> --width 840 --height 620',
1225
+ 'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
1226
+ 'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
1227
+ 'pmx-canvas node update <node-id> --pinned true',
1154
1228
  'pmx-canvas node update <node-id> --lock-arrange',
1155
1229
  ], async (args) => {
1156
1230
  const { positional, flags } = parseFlags(args);
@@ -1160,6 +1234,7 @@ cmd('node update', 'Update a node by ID', [
1160
1234
  if (!id) die('Missing node ID', 'pmx-canvas node update <node-id> --title "New Title"');
1161
1235
 
1162
1236
  const body: Record<string, unknown> = {};
1237
+ await applyStructuredNodeUpdateFlags(body, flags);
1163
1238
  if (flags.title && flags.title !== true) body.title = flags.title;
1164
1239
  if (flags.content && flags.content !== true) body.content = flags.content;
1165
1240
  if (flags.stdin) body.content = await readStdin();
@@ -1168,6 +1243,17 @@ cmd('node update', 'Update a node by ID', [
1168
1243
  const y = optionalFiniteFlag(flags, 'y', 'Use a finite number, e.g. --y 300');
1169
1244
  const width = optionalPositiveFiniteFlag(flags, 'width', 'Use a positive number, e.g. --width 840');
1170
1245
  const height = optionalPositiveFiniteFlag(flags, 'height', 'Use a positive number, e.g. --height 620');
1246
+ const nodeHeight = optionalPositiveFiniteFlagWithAliases(
1247
+ flags,
1248
+ 'Use a positive number, e.g. --node-height 620',
1249
+ 'node-height',
1250
+ 'nodeHeight',
1251
+ );
1252
+ if (height !== undefined && nodeHeight !== undefined) {
1253
+ die('Use either --height/--node-height, not both.');
1254
+ }
1255
+ const frameHeight = height ?? nodeHeight;
1256
+ const pinned = optionalBooleanFlag(flags, 'pinned', 'Use --pinned true or --pinned false');
1171
1257
  if (flags['lock-arrange'] && flags['unlock-arrange']) {
1172
1258
  die('Use either --lock-arrange or --unlock-arrange, not both.');
1173
1259
  }
@@ -1177,7 +1263,7 @@ cmd('node update', 'Update a node by ID', [
1177
1263
  ? false
1178
1264
  : undefined;
1179
1265
 
1180
- if (x !== undefined || y !== undefined || width !== undefined || height !== undefined || arrangeLocked !== undefined) {
1266
+ if (x !== undefined || y !== undefined || width !== undefined || frameHeight !== undefined || arrangeLocked !== undefined) {
1181
1267
  const existing = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as {
1182
1268
  position: { x: number; y: number };
1183
1269
  size: { width: number; height: number };
@@ -1191,25 +1277,24 @@ cmd('node update', 'Update a node by ID', [
1191
1277
  };
1192
1278
  }
1193
1279
 
1194
- if (width !== undefined || height !== undefined) {
1280
+ if (width !== undefined || frameHeight !== undefined) {
1195
1281
  body.size = {
1196
1282
  width: width ?? existing.size.width,
1197
- height: height ?? existing.size.height,
1283
+ height: frameHeight ?? existing.size.height,
1198
1284
  };
1199
1285
  }
1200
1286
 
1201
1287
  if (arrangeLocked !== undefined) {
1202
- body.data = {
1203
- ...existing.data,
1204
- arrangeLocked,
1205
- };
1288
+ body.arrangeLocked = arrangeLocked;
1206
1289
  }
1207
1290
  }
1208
1291
 
1292
+ if (pinned !== undefined) body.pinned = pinned;
1293
+
1209
1294
  if (Object.keys(body).length === 0) {
1210
1295
  die(
1211
1296
  'No updates specified',
1212
- 'Use --title, --content, --x, --y, --width, --height, --lock-arrange, --unlock-arrange, or --stdin',
1297
+ 'Use --title, --content, --x, --y, --width, --height, --pinned, --lock-arrange, --unlock-arrange, or --stdin',
1213
1298
  );
1214
1299
  }
1215
1300
 
@@ -1396,6 +1481,29 @@ cmd('focus', 'Pan viewport to center on a node', [
1396
1481
  output(result);
1397
1482
  });
1398
1483
 
1484
+ cmd('fit', 'Fit the viewport to all nodes or a selected subset', [
1485
+ 'pmx-canvas fit',
1486
+ 'pmx-canvas fit --width 1440 --height 900 --padding 80',
1487
+ 'pmx-canvas fit node-a node-b',
1488
+ ], async (args) => {
1489
+ const { positional, flags } = parseFlags(args);
1490
+ if (flags.help || flags.h) return showCommandHelp('fit');
1491
+
1492
+ const body: Record<string, unknown> = {};
1493
+ const width = optionalPositiveFiniteFlag(flags, 'width', 'Use a positive number, e.g. --width 1440');
1494
+ const height = optionalPositiveFiniteFlag(flags, 'height', 'Use a positive number, e.g. --height 900');
1495
+ const padding = optionalPositiveFiniteFlag(flags, 'padding', 'Use a positive number, e.g. --padding 80');
1496
+ const maxScale = optionalPositiveFiniteFlag(flags, 'max-scale', 'Use a positive number, e.g. --max-scale 1');
1497
+ if (width !== undefined) body.width = width;
1498
+ if (height !== undefined) body.height = height;
1499
+ if (padding !== undefined) body.padding = padding;
1500
+ if (maxScale !== undefined) body.maxScale = maxScale;
1501
+ if (positional.length > 0) body.nodeIds = positional;
1502
+
1503
+ const result = await api('POST', '/api/canvas/fit', body);
1504
+ output(result);
1505
+ });
1506
+
1399
1507
  // ── external-app add ─────────────────────────────────────────
1400
1508
  cmd('external-app add', 'Create a hosted external app node', [
1401
1509
  'pmx-canvas external-app add --kind excalidraw --title "Diagram"',
@@ -1816,7 +1924,7 @@ cmd('webview start', 'Start or replace the Bun.WebView automation session', [
1816
1924
  if (chromeArgv.length > 0) body.chromeArgv = chromeArgv;
1817
1925
  }
1818
1926
 
1819
- const result = await api('POST', '/api/workbench/webview/start', body);
1927
+ const result = await api('POST', '/api/workbench/webview/start', body, { allowErrorJson: true });
1820
1928
  output(result);
1821
1929
  });
1822
1930
 
@@ -2112,6 +2220,13 @@ function showCommandHelp(name: string): void {
2112
2220
  console.log('\nViewport:');
2113
2221
  console.log(' --no-pan Select/raise the node without moving the viewport');
2114
2222
  }
2223
+ if (name === 'fit') {
2224
+ console.log('\nViewport:');
2225
+ console.log(' --width <px> Viewport width used for fit math (default 1440)');
2226
+ console.log(' --height <px> Viewport height used for fit math (default 900)');
2227
+ console.log(' --padding <px> World-space padding around fitted nodes (default 60)');
2228
+ console.log(' --max-scale <scale> Maximum zoom scale (default 1)');
2229
+ }
2115
2230
  if (name === 'external-app add') {
2116
2231
  console.log('\nOptions:');
2117
2232
  console.log(' --kind excalidraw External app kind to create');
@@ -2161,6 +2276,7 @@ Canvas commands:
2161
2276
  pmx-canvas validate spec Validate json-render/graph payloads without creating nodes
2162
2277
  pmx-canvas watch [options] Watch semantic canvas changes over SSE
2163
2278
  pmx-canvas focus <id> Pan viewport to node
2279
+ pmx-canvas fit [id ...] Fit viewport to canvas or selected nodes
2164
2280
  pmx-canvas external-app add Add hosted external apps like Excalidraw
2165
2281
  pmx-canvas webview status Show WebView automation status
2166
2282
  pmx-canvas webview start [options] Start or replace automation session
@@ -2222,6 +2338,7 @@ Examples:
2222
2338
  pmx-canvas edge add --from-search "DVT O3 — GitOps" --to-search "deep work trend" --type relation
2223
2339
  pmx-canvas search "authentication"
2224
2340
  pmx-canvas open
2341
+ pmx-canvas fit --width 1440 --height 900
2225
2342
  pmx-canvas layout --summary
2226
2343
  pmx-canvas arrange --layout column
2227
2344
  pmx-canvas batch --file ./canvas-ops.json
package/src/cli/index.ts CHANGED
@@ -30,7 +30,7 @@ if (args.includes('--version') || args.includes('-v')) {
30
30
  // If first arg is a known subcommand (not a --flag), route to the agent CLI.
31
31
  const AGENT_COMMANDS = new Set([
32
32
  'node', 'edge', 'search', 'layout', 'status', 'arrange', 'focus',
33
- 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
33
+ 'fit', 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
34
34
  'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'graph', 'batch', 'validate', 'serve',
35
35
  ]);
36
36
 
@@ -28,6 +28,7 @@ import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-brid
28
28
  import { getNodeIcon } from '../icons';
29
29
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
30
30
  import type { CanvasNodeState } from '../types';
31
+ import { computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
31
32
  import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './snap-guides';
32
33
  import { useNodeDrag } from './use-node-drag';
33
34
  import { useNodeResize } from './use-node-resize';
@@ -141,12 +142,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
141
142
  const bodyRef = useRef<HTMLDivElement>(null);
142
143
  const hasAutoFit = useRef(false);
143
144
  const autoFitPersistTimer = useRef<number | null>(null);
144
- const AUTO_FIT_MAX = 600;
145
- const TITLEBAR_HEIGHT = 37;
146
- const isExtAppNode = node.type === 'mcp-app' && node.data.mode === 'ext-app';
147
145
 
148
146
  useEffect(() => {
149
- if (hasAutoFit.current || node.collapsed || node.dockPosition || node.type === 'group' || isExtAppNode) return;
147
+ if (hasAutoFit.current || !shouldAutoFitNode(node)) return;
150
148
  const body = bodyRef.current;
151
149
  if (!body) return;
152
150
 
@@ -156,9 +154,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
156
154
  return;
157
155
  }
158
156
  const contentHeight = body.scrollHeight;
159
- if (contentHeight <= 0) return;
157
+ const fitHeight = computeAutoFitHeight(node, contentHeight);
158
+ if (fitHeight === null) return;
160
159
 
161
- const fitHeight = Math.min(contentHeight + TITLEBAR_HEIGHT, AUTO_FIT_MAX);
162
160
  // Only resize if the fit height differs meaningfully from current
163
161
  if (Math.abs(fitHeight - node.size.height) > 8) {
164
162
  resizeNode(node.id, { width: node.size.width, height: fitHeight });
@@ -181,7 +179,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
181
179
  autoFitPersistTimer.current = null;
182
180
  }
183
181
  };
184
- }, [node.id, node.type, isExtAppNode, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
182
+ }, [node.id, node.type, node.data.mode, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
185
183
 
186
184
  const isPinned = node.pinned;
187
185
  const isTrace = node.type === 'trace';
@@ -0,0 +1,21 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
4
+ export const AUTO_FIT_MAX_HEIGHT = 600;
5
+
6
+ function isExtAppNode(node: CanvasNodeState): boolean {
7
+ return node.type === 'mcp-app' && node.data.mode === 'ext-app';
8
+ }
9
+
10
+ function hasExplicitStructuredFrame(node: CanvasNodeState): boolean {
11
+ return node.type === 'graph' || node.type === 'json-render';
12
+ }
13
+
14
+ export function shouldAutoFitNode(node: CanvasNodeState): boolean {
15
+ return !node.collapsed && !node.dockPosition && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node);
16
+ }
17
+
18
+ export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
19
+ if (!shouldAutoFitNode(node) || contentHeight <= 0) return null;
20
+ return Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
21
+ }
@@ -23,6 +23,8 @@ type DisplayMode = 'inline' | 'fullscreen' | 'pip';
23
23
  const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
24
24
 
25
25
  interface ExtAppHostDimensionsTarget {
26
+ clientWidth?: number;
27
+ clientHeight?: number;
26
28
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
27
29
  }
28
30
 
@@ -120,8 +122,8 @@ export function resolveExtAppContainerDimensions(
120
122
  ): { width: number; height: number } {
121
123
  const rect = target?.getBoundingClientRect();
122
124
  return {
123
- width: positiveDimension(rect?.width ?? 0, fallback.width),
124
- height: positiveDimension(rect?.height ?? 0, fallback.height),
125
+ width: positiveDimension(target?.clientWidth ?? 0, positiveDimension(rect?.width ?? 0, fallback.width)),
126
+ height: positiveDimension(target?.clientHeight ?? 0, positiveDimension(rect?.height ?? 0, fallback.height)),
125
127
  };
126
128
  }
127
129
 
@@ -7,13 +7,22 @@
7
7
  */
8
8
 
9
9
  import { defineCatalog } from '@json-render/core';
10
+ import { z } from 'zod';
10
11
  import { schema } from './schema.js';
11
12
  import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
12
13
  import { chartComponentDefinitions } from './charts/definitions';
13
14
  import { extraChartComponentDefinitions } from './charts/extra-definitions';
14
15
 
16
+ const badgeDefinition = shadcnComponentDefinitions.Badge;
17
+
15
18
  export const allComponentDefinitions = {
16
19
  ...shadcnComponentDefinitions,
20
+ Badge: {
21
+ ...badgeDefinition,
22
+ props: badgeDefinition.props.extend({
23
+ variant: z.enum(['default', 'secondary', 'destructive', 'outline', 'success', 'info', 'warning', 'error', 'danger']).nullable(),
24
+ }),
25
+ },
17
26
  ...chartComponentDefinitions,
18
27
  ...extraChartComponentDefinitions,
19
28
  };
@@ -143,6 +143,67 @@ button {
143
143
  cursor: pointer;
144
144
  }
145
145
 
146
+ .pmx-badge {
147
+ display: inline-flex;
148
+ width: fit-content;
149
+ align-items: center;
150
+ justify-content: center;
151
+ gap: 0.25rem;
152
+ overflow: hidden;
153
+ white-space: nowrap;
154
+ border: 1px solid transparent;
155
+ border-radius: 9999px;
156
+ padding: 0.125rem 0.5rem;
157
+ font-size: 0.75rem;
158
+ font-weight: 500;
159
+ line-height: 1.25rem;
160
+ }
161
+
162
+ .pmx-badge--default {
163
+ background: var(--primary);
164
+ color: var(--primary-foreground);
165
+ }
166
+
167
+ .pmx-badge--secondary {
168
+ background: var(--secondary);
169
+ color: var(--secondary-foreground);
170
+ }
171
+
172
+ .pmx-badge--destructive {
173
+ background: var(--destructive);
174
+ color: var(--destructive-foreground);
175
+ }
176
+
177
+ .pmx-badge--outline {
178
+ border-color: var(--border);
179
+ color: var(--foreground);
180
+ }
181
+
182
+ .pmx-badge--success {
183
+ border-color: color-mix(in oklch, var(--chart-2) 45%, transparent);
184
+ background: color-mix(in oklch, var(--chart-2) 16%, transparent);
185
+ color: var(--chart-2);
186
+ }
187
+
188
+ .pmx-badge--info {
189
+ border-color: color-mix(in oklch, var(--chart-1) 45%, transparent);
190
+ background: color-mix(in oklch, var(--chart-1) 14%, transparent);
191
+ color: var(--chart-1);
192
+ }
193
+
194
+ .pmx-badge--warning {
195
+ border-color: color-mix(in oklch, var(--chart-3) 50%, transparent);
196
+ background: color-mix(in oklch, var(--chart-3) 18%, transparent);
197
+ color: var(--chart-3);
198
+ }
199
+
200
+ .pmx-badge--error,
201
+ .pmx-badge--danger {
202
+ border-color: color-mix(in oklch, var(--destructive) 55%, transparent);
203
+ background: color-mix(in oklch, var(--destructive) 18%, transparent);
204
+ color: var(--destructive);
205
+ }
206
+
146
207
  /* -- Chart components -- */
147
208
 
148
209
  .pmx-chart {