pmx-canvas 0.1.7 → 0.1.9

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.
@@ -0,0 +1,5 @@
1
+ import type { CanvasNodeState } from '../types';
2
+ export declare const AUTO_FIT_MAX_HEIGHT = 600;
3
+ export declare const AUTO_FIT_TITLEBAR_HEIGHT = 37;
4
+ export declare function shouldAutoFitNode(node: CanvasNodeState): boolean;
5
+ export declare function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null;
@@ -5,6 +5,8 @@ type IframeLoadTarget = Pick<HTMLIFrameElement, 'addEventListener' | 'removeEven
5
5
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
6
6
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
7
7
  interface ExtAppHostDimensionsTarget {
8
+ clientWidth?: number;
9
+ clientHeight?: number;
8
10
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
9
11
  }
10
12
  export declare function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void>;
@@ -4,7 +4,7 @@ export interface JsonRenderSpec {
4
4
  state?: Record<string, unknown>;
5
5
  }
6
6
  export interface JsonRenderNodeInput {
7
- title: string;
7
+ title?: string;
8
8
  spec: unknown;
9
9
  x?: number;
10
10
  y?: number;
@@ -44,9 +44,11 @@ export declare const GRAPH_NODE_SIZE: {
44
44
  height: number;
45
45
  };
46
46
  export type GraphChartType = 'LineChart' | 'BarChart' | 'PieChart' | 'AreaChart' | 'ScatterChart' | 'RadarChart' | 'StackedBarChart' | 'ComposedChart';
47
+ export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?: string): string;
47
48
  export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
48
49
  export declare function normalizeGraphType(value: string): GraphChartType;
49
50
  export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
51
+ export declare function buildGraphConfig(input: GraphNodeInput): Record<string, unknown>;
50
52
  export declare function createJsonRenderNodeData(nodeId: string, title: string, spec: JsonRenderSpec, extra?: Record<string, unknown>): Record<string, unknown>;
51
53
  export declare function buildJsonRenderViewerHtml(options: {
52
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.7",
3
+ "version": "0.1.9",
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
@@ -270,6 +273,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
270
273
  - `content`: for most types, this is markdown text. For `file` type, pass the **file path**
271
274
  (e.g., `"src/auth/login.ts"`) — the server auto-loads the file content and watches for changes.
272
275
  For `image` type, pass a file path, URL, or data URI.
276
+ - `path`: compatibility alias for image paths only; prefer `content` for new image calls
273
277
  - `x`, `y`: position (auto-placed if omitted — prefer omitting for auto-layout)
274
278
  - `width`, `height`: dimensions (sensible defaults provided)
275
279
  - `color`: semantic color
@@ -278,7 +282,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
278
282
 
279
283
  **`canvas_update_node`** — Update an existing node
280
284
  - `id` (required): node to update
281
- - 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
282
288
  - Use to update status nodes as work progresses
283
289
 
284
290
  **`canvas_remove_node`** — Remove a node and all its connected edges
@@ -306,8 +312,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
306
312
  ```
307
313
 
308
314
  **`canvas_add_json_render_node`** — Add a native json-render node
309
- - Required: `title`, `spec`
310
- - The `spec` must be a complete json-render object with `root`, `elements`, and optional `state`
315
+ - Required: `spec`; `title` is optional and inferred from the root element when omitted
316
+ - Prefer a complete json-render object with `root`, `elements`, and optional `state`
317
+ - Legacy bare component specs like `{ type: "Badge", props: {...} }` are accepted and wrapped into a one-element document for compatibility
311
318
  - Use this when you want a structured UI panel rendered directly inside PMX Canvas
312
319
  - For shadcn `Badge`, prefer `props.text` with variants `default`, `secondary`, `destructive`, or
313
320
  `outline`. Legacy `props.label` and status variants (`success`, `info`, `warning`, `error`,
@@ -355,6 +362,10 @@ ID extraction for mixed tool responses:
355
362
  - Returns the normalized json-render spec the server would accept
356
363
  - Use this when you want a dry run before creating a `json-render` or `graph` node
357
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
+
358
369
  **Batch graph creation**
359
370
  - Use `graph.add` inside `canvas_batch` / `pmx-canvas batch` when you need a graph node as part of
360
371
  a larger one-shot build.
@@ -510,6 +521,8 @@ tools below operate on the live canvas state.
510
521
  - Required: exactly one of `expression` (single JS expression) or `script` (multi-statement body)
511
522
  - `script` is wrapped in an async IIFE, so top-level `await` works inside script bodies
512
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.
513
526
  - Example: read the count of rendered `.canvas-node` elements:
514
527
 
515
528
  ```typescript
@@ -664,6 +677,7 @@ All POST/PATCH endpoints accept `Content-Type: application/json`. Default base U
664
677
  | POST | `/api/canvas/group/ungroup` | Ungroup |
665
678
  | POST | `/api/canvas/arrange` | Auto-arrange |
666
679
  | POST | `/api/canvas/focus` | Center viewport on node |
680
+ | POST | `/api/canvas/fit` | Fit viewport to canvas bounds or selected nodes |
667
681
  | POST | `/api/canvas/clear` | Clear canvas |
668
682
  | POST | `/api/canvas/update` | Batch update positions |
669
683
  | GET | `/api/canvas/spatial-context` | Spatial clusters and reading order |
@@ -40,11 +40,25 @@ bun run test:all # Bun suite + browser smoke
40
40
  Manual browser validation also requires a fresh client bundle. `bun run test:web-canvas`
41
41
  already does this for you.
42
42
 
43
+ ## Coverage Notes
44
+
45
+ - `bun run test:coverage` covers the Bun unit suite under `tests/unit/`
46
+ - Coverage output is written to `coverage/lcov.info` and also printed as a text summary
47
+ - CI currently uses that same unit-test coverage command, then runs browser smoke separately
48
+ - Do not describe `test:coverage` as full-stack coverage; Playwright coverage is not wired in here
49
+
43
50
  ## Current Project Test Surface
44
51
 
45
52
  - Bun tests live under `tests/unit/`
46
53
  - Playwright browser smoke lives under `tests/e2e/`
47
- - CI runs coverage plus the browser smoke flow
54
+ - CI runs Bun coverage plus the browser smoke flow
55
+
56
+ ## WebView Automation Caveat
57
+
58
+ - Some Linux/CI environments expose `Bun.WebView` but still cannot start a usable automation
59
+ session within the timeout window
60
+ - When testing WebView automation, treat a cleanly reported unsupported/timeout runtime boundary
61
+ as distinct from a product regression
48
62
 
49
63
  Prefer extending the existing suites before inventing a one-off script.
50
64
 
@@ -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
@@ -503,6 +503,41 @@ async function readTextInput(
503
503
  die(options.requiredMessage, options.hint);
504
504
  }
505
505
 
506
+ async function readOptionalTextInput(
507
+ flags: Record<string, string | true>,
508
+ options: {
509
+ fileFlags?: string[];
510
+ valueFlags?: string[];
511
+ allowStdin?: boolean;
512
+ label: string;
513
+ hint: string;
514
+ },
515
+ ): Promise<string | undefined> {
516
+ for (const name of options.fileFlags ?? []) {
517
+ const path = getStringFlag(flags, name);
518
+ if (!path) continue;
519
+ try {
520
+ return readFileSync(path, 'utf-8');
521
+ } catch (error) {
522
+ die(
523
+ `Unable to read --${name}: ${error instanceof Error ? error.message : String(error)}`,
524
+ options.hint,
525
+ );
526
+ }
527
+ }
528
+
529
+ for (const name of options.valueFlags ?? []) {
530
+ const value = getStringFlag(flags, name);
531
+ if (value !== undefined) return value;
532
+ }
533
+
534
+ if (options.allowStdin && flags.stdin) {
535
+ return await readStdin();
536
+ }
537
+
538
+ return undefined;
539
+ }
540
+
506
541
  function applyCommonGeometryFlags(
507
542
  body: Record<string, unknown>,
508
543
  flags: Record<string, string | true>,
@@ -518,15 +553,33 @@ function applyCommonGeometryFlags(
518
553
  if (height !== undefined) body.height = height;
519
554
  }
520
555
 
556
+ async function applyStructuredNodeUpdateFlags(
557
+ body: Record<string, unknown>,
558
+ flags: Record<string, string | true>,
559
+ ): Promise<void> {
560
+ const specRaw = await readOptionalTextInput(flags, {
561
+ fileFlags: ['spec-file'],
562
+ valueFlags: ['spec-json'],
563
+ allowStdin: false,
564
+ label: 'JSON spec',
565
+ hint: 'Use: pmx-canvas node update <node-id> --spec-file ./new-spec.json',
566
+ });
567
+ if (specRaw !== undefined) {
568
+ body.spec = parseJsonValue(specRaw, 'JSON spec', 'Use: pmx-canvas node update <node-id> --spec-file ./new-spec.json');
569
+ }
570
+
571
+ const graphPatch = await buildGraphRequestBody(flags, { requireData: false, allowStdin: false });
572
+ for (const [key, value] of Object.entries(graphPatch)) {
573
+ body[key === 'height' ? 'chartHeight' : key] = value;
574
+ }
575
+ }
576
+
521
577
  async function buildJsonRenderRequestBody(
522
578
  flags: Record<string, string | true>,
523
579
  ): Promise<Record<string, unknown>> {
524
580
  const hint =
525
- 'Use: pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json';
581
+ 'Use: pmx-canvas node add --type json-render --spec-file ./dashboard.json --title "Ops Dashboard"';
526
582
  const title = typeof flags.title === 'string' ? flags.title.trim() : '';
527
- if (!title) {
528
- die('json-render nodes require --title.', hint);
529
- }
530
583
 
531
584
  const rawSpec = await readTextInput(flags, {
532
585
  fileFlags: ['spec-file'],
@@ -538,7 +591,7 @@ async function buildJsonRenderRequestBody(
538
591
  });
539
592
 
540
593
  const spec = parseJsonValue(rawSpec, 'JSON spec', hint);
541
- const body: Record<string, unknown> = { title, spec };
594
+ const body: Record<string, unknown> = { ...(title ? { title } : {}), spec };
542
595
  applyCommonGeometryFlags(body, flags, {
543
596
  x: 'Use a finite number, e.g. --x 500',
544
597
  y: 'Use a finite number, e.g. --y 300',
@@ -550,23 +603,30 @@ async function buildJsonRenderRequestBody(
550
603
 
551
604
  async function buildGraphRequestBody(
552
605
  flags: Record<string, string | true>,
606
+ options: { requireData?: boolean; allowStdin?: boolean } = {},
553
607
  ): Promise<Record<string, unknown>> {
608
+ const requireData = options.requireData !== false;
609
+ const allowStdin = options.allowStdin !== false;
554
610
  const hint =
555
611
  'Use: pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value';
556
- const rawData = await readTextInput(flags, {
612
+
613
+ const body: Record<string, unknown> = {
614
+ ...(requireData ? { graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line' } : {}),
615
+ };
616
+ const rawData = await readOptionalTextInput(flags, {
557
617
  fileFlags: ['data-file'],
558
618
  valueFlags: ['data-json', 'data'],
559
- allowStdin: true,
619
+ allowStdin,
560
620
  label: 'graph JSON dataset',
561
621
  hint,
562
- requiredMessage: 'Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.',
563
622
  });
564
- const data = parseRecordArrayJson(rawData, hint);
565
-
566
- const body: Record<string, unknown> = {
567
- graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line',
568
- data,
569
- };
623
+ if (rawData !== undefined) {
624
+ body.data = parseRecordArrayJson(rawData, hint);
625
+ } else if (requireData) {
626
+ die('Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.', hint);
627
+ }
628
+ const graphType = getStringFlag(flags, 'graph-type', 'graphType');
629
+ if (graphType) body.graphType = graphType;
570
630
  if (typeof flags.title === 'string') body.title = flags.title;
571
631
  const xKey = getStringFlag(flags, 'x-key', 'xKey');
572
632
  const yKey = getStringFlag(flags, 'y-key', 'yKey');
@@ -985,8 +1045,11 @@ cmd('node add', 'Add a node to the canvas', [
985
1045
  const body: Record<string, unknown> = { type };
986
1046
  if (flags.title) body.title = flags.title;
987
1047
  const webpageUrl = getStringFlag(flags, 'url');
1048
+ const imagePath = getStringFlag(flags, 'path');
988
1049
  if (type === 'webpage' && webpageUrl) {
989
1050
  body.url = webpageUrl;
1051
+ } else if (type === 'image' && imagePath && !flags.content) {
1052
+ body.content = imagePath;
990
1053
  } else if (flags.content) {
991
1054
  body.content = flags.content;
992
1055
  }
@@ -1151,6 +1214,8 @@ cmd('node update', 'Update a node by ID', [
1151
1214
  'pmx-canvas node update <node-id> --content "Updated content"',
1152
1215
  'pmx-canvas node update <node-id> --title "Moved" --x 500 --y 300',
1153
1216
  'pmx-canvas node update <node-id> --width 840 --height 620',
1217
+ 'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
1218
+ 'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
1154
1219
  'pmx-canvas node update <node-id> --lock-arrange',
1155
1220
  ], async (args) => {
1156
1221
  const { positional, flags } = parseFlags(args);
@@ -1160,6 +1225,7 @@ cmd('node update', 'Update a node by ID', [
1160
1225
  if (!id) die('Missing node ID', 'pmx-canvas node update <node-id> --title "New Title"');
1161
1226
 
1162
1227
  const body: Record<string, unknown> = {};
1228
+ await applyStructuredNodeUpdateFlags(body, flags);
1163
1229
  if (flags.title && flags.title !== true) body.title = flags.title;
1164
1230
  if (flags.content && flags.content !== true) body.content = flags.content;
1165
1231
  if (flags.stdin) body.content = await readStdin();
@@ -1199,10 +1265,7 @@ cmd('node update', 'Update a node by ID', [
1199
1265
  }
1200
1266
 
1201
1267
  if (arrangeLocked !== undefined) {
1202
- body.data = {
1203
- ...existing.data,
1204
- arrangeLocked,
1205
- };
1268
+ body.arrangeLocked = arrangeLocked;
1206
1269
  }
1207
1270
  }
1208
1271
 
@@ -1396,6 +1459,29 @@ cmd('focus', 'Pan viewport to center on a node', [
1396
1459
  output(result);
1397
1460
  });
1398
1461
 
1462
+ cmd('fit', 'Fit the viewport to all nodes or a selected subset', [
1463
+ 'pmx-canvas fit',
1464
+ 'pmx-canvas fit --width 1440 --height 900 --padding 80',
1465
+ 'pmx-canvas fit node-a node-b',
1466
+ ], async (args) => {
1467
+ const { positional, flags } = parseFlags(args);
1468
+ if (flags.help || flags.h) return showCommandHelp('fit');
1469
+
1470
+ const body: Record<string, unknown> = {};
1471
+ const width = optionalPositiveFiniteFlag(flags, 'width', 'Use a positive number, e.g. --width 1440');
1472
+ const height = optionalPositiveFiniteFlag(flags, 'height', 'Use a positive number, e.g. --height 900');
1473
+ const padding = optionalPositiveFiniteFlag(flags, 'padding', 'Use a positive number, e.g. --padding 80');
1474
+ const maxScale = optionalPositiveFiniteFlag(flags, 'max-scale', 'Use a positive number, e.g. --max-scale 1');
1475
+ if (width !== undefined) body.width = width;
1476
+ if (height !== undefined) body.height = height;
1477
+ if (padding !== undefined) body.padding = padding;
1478
+ if (maxScale !== undefined) body.maxScale = maxScale;
1479
+ if (positional.length > 0) body.nodeIds = positional;
1480
+
1481
+ const result = await api('POST', '/api/canvas/fit', body);
1482
+ output(result);
1483
+ });
1484
+
1399
1485
  // ── external-app add ─────────────────────────────────────────
1400
1486
  cmd('external-app add', 'Create a hosted external app node', [
1401
1487
  'pmx-canvas external-app add --kind excalidraw --title "Diagram"',
@@ -1816,7 +1902,7 @@ cmd('webview start', 'Start or replace the Bun.WebView automation session', [
1816
1902
  if (chromeArgv.length > 0) body.chromeArgv = chromeArgv;
1817
1903
  }
1818
1904
 
1819
- const result = await api('POST', '/api/workbench/webview/start', body);
1905
+ const result = await api('POST', '/api/workbench/webview/start', body, { allowErrorJson: true });
1820
1906
  output(result);
1821
1907
  });
1822
1908
 
@@ -2112,6 +2198,13 @@ function showCommandHelp(name: string): void {
2112
2198
  console.log('\nViewport:');
2113
2199
  console.log(' --no-pan Select/raise the node without moving the viewport');
2114
2200
  }
2201
+ if (name === 'fit') {
2202
+ console.log('\nViewport:');
2203
+ console.log(' --width <px> Viewport width used for fit math (default 1440)');
2204
+ console.log(' --height <px> Viewport height used for fit math (default 900)');
2205
+ console.log(' --padding <px> World-space padding around fitted nodes (default 60)');
2206
+ console.log(' --max-scale <scale> Maximum zoom scale (default 1)');
2207
+ }
2115
2208
  if (name === 'external-app add') {
2116
2209
  console.log('\nOptions:');
2117
2210
  console.log(' --kind excalidraw External app kind to create');
@@ -2161,6 +2254,7 @@ Canvas commands:
2161
2254
  pmx-canvas validate spec Validate json-render/graph payloads without creating nodes
2162
2255
  pmx-canvas watch [options] Watch semantic canvas changes over SSE
2163
2256
  pmx-canvas focus <id> Pan viewport to node
2257
+ pmx-canvas fit [id ...] Fit viewport to canvas or selected nodes
2164
2258
  pmx-canvas external-app add Add hosted external apps like Excalidraw
2165
2259
  pmx-canvas webview status Show WebView automation status
2166
2260
  pmx-canvas webview start [options] Start or replace automation session
@@ -2222,6 +2316,7 @@ Examples:
2222
2316
  pmx-canvas edge add --from-search "DVT O3 — GitOps" --to-search "deep work trend" --type relation
2223
2317
  pmx-canvas search "authentication"
2224
2318
  pmx-canvas open
2319
+ pmx-canvas fit --width 1440 --height 900
2225
2320
  pmx-canvas layout --summary
2226
2321
  pmx-canvas arrange --layout column
2227
2322
  pmx-canvas batch --file ./canvas-ops.json
@@ -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_MAX_HEIGHT = 600;
4
+ export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
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') && node.size.height > AUTO_FIT_MAX_HEIGHT;
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