pmx-canvas 0.4.6 → 0.4.7
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.
- package/CHANGELOG.md +62 -0
- package/Readme.md +56 -33
- package/dist/canvas/global.css +101 -3
- package/dist/canvas/index.js +87 -83
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +8 -0
- package/dist/types/server/canvas-validation.d.ts +15 -21
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +19 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +17 -0
- package/docs/mcp.md +12 -0
- package/docs/node-types.md +45 -1
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +68 -7
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/cli/shared.ts +34 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
- package/src/client/canvas/auto-fit.ts +10 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/sse-bridge.ts +4 -1
- package/src/client/theme/global.css +101 -3
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +299 -4
- package/src/server/canvas-operations.ts +44 -17
- package/src/server/canvas-state.ts +20 -10
- package/src/server/canvas-validation.ts +50 -41
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/ops/viewport.ts +7 -1
- package/src/server/server.ts +106 -5
- package/src/server/web-artifacts.ts +109 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/node-sizes.ts +52 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -105,6 +105,7 @@ exit 1 on failure.
|
|
|
105
105
|
| Persistent context cards | `context` via `canvas_node` |
|
|
106
106
|
| Event/check stream | `ledger` or `trace` via `canvas_node` |
|
|
107
107
|
| Local source with live updates | `file` via `canvas_node` |
|
|
108
|
+
| Tabular data (`.csv`/`.tsv`), a PDF, or any other file on disk (0.4.6+) | `file` via `canvas_node` — CSV/TSV render as tables, PDFs render inline, other binaries show a size placeholder. Never paste a CSV into a markdown fence. |
|
|
108
109
|
| Code review / unified diff (0.4.6+) | `diff` via `canvas_node` (content = diff text; link to its file node with a `references` edge) |
|
|
109
110
|
| Flowchart / sequence / state diagram (0.4.6+) | `mermaid` via `canvas_node` (content = mermaid source; renders client-side, no hosted app) |
|
|
110
111
|
| Image | `image` via `canvas_node` |
|
|
@@ -113,6 +114,9 @@ exit 1 on failure.
|
|
|
113
114
|
| Chart | `graph` via `canvas_render` |
|
|
114
115
|
| Live work-item board (0.4.6+) | `canvas_render { action: "workboard" }` — one board node, auto-refreshes on work-item changes |
|
|
115
116
|
| Generated communication surface | HTML primitive via `canvas_node` |
|
|
117
|
+
| Give the agent tasks + watch progress + loop (0.4.7+) | `ax-board` HTML primitive — a live AX control surface, created AX-enabled |
|
|
118
|
+
| The same, drawn as a task flow, and materializable to real nodes+edges (0.4.7+) | `ax-flow` HTML primitive — **Materialize to board** lays the steps out as nodes joined by `flow` edges with a loop-back edge, each linked to a work item |
|
|
119
|
+
| Drive a flow WITHOUT the panel (0.4.7+) | Materialized step nodes carry native Start/Done/Blocked controls, and the anchor adds Run loop/Stop + steer. The native loop runs server-side, so it survives a browser reload and keeps advancing while the tab is closed. |
|
|
116
120
|
| Self-contained HTML/JS | `html` via `canvas_node` |
|
|
117
121
|
| Hosted interactive MCP app | `canvas_app { action: "open-mcp-app" }` |
|
|
118
122
|
| Excalidraw diagram (interactive/human drawing) | `canvas_app { action: "diagram" }` — prefer `mermaid` for agent-authored diagrams |
|
|
@@ -190,10 +194,23 @@ distant nodes, an auto-placed node lands off-camera.
|
|
|
190
194
|
3. Never fit the whole board to "show" new work — on a board with outliers that miniaturizes
|
|
191
195
|
everything. Always pass explicit `nodeIds` to `fit`.
|
|
192
196
|
4. Never leave user-facing output at far coordinates without a focus/fit.
|
|
197
|
+
5. **Do not hand-compute a whole board's coordinates.** Create the nodes (omitting x/y), then
|
|
198
|
+
`canvas_view { action: "arrange", layout: "grid" | "column" | "flow" }` and finish with a
|
|
199
|
+
`fit` over the new ids. Manual pixel math is what produces long-line, unbalanced boards.
|
|
200
|
+
6. `fit` sizes itself to the connected browser window (0.4.6+) — you do not need to guess
|
|
201
|
+
`width`/`height`. Pass them only to fit for a window other than the human's.
|
|
202
|
+
7. **Docking is explicit (0.4.6+).** Nodes render on the canvas where you put them; a node is
|
|
203
|
+
only a HUD pill if you set `dockPosition`. An edge whose endpoint is docked cannot be drawn —
|
|
204
|
+
`validate` reports it as `hiddenEdgeEndpoints` and fails `ok`, because the edge visually
|
|
205
|
+
trails off into empty canvas.
|
|
193
206
|
|
|
194
207
|
**Size for content.** Omitting `width`/`height` gives readable per-type defaults — prefer them:
|
|
195
208
|
markdown 640×420, status 360×200, file 520×360, diff 640×420, mermaid 640×460, html 720×640,
|
|
196
|
-
graph 760×520, mcp-app 960×600, web-artifact 960×720.
|
|
209
|
+
graph 760×520, mcp-app 960×600, web-artifact 960×720. A *hosted* app opened with
|
|
210
|
+
`canvas_app { action: "open-mcp-app" | "diagram" }` — including the Excalidraw diagram preset — is
|
|
211
|
+
the exception: it opens at 720×500, not 960×600 (960×600 is the default for an `mcp-app` node you
|
|
212
|
+
create directly). Pass `width`/`height` to that action when you want a bigger diagram tile.
|
|
213
|
+
Since 0.4.6 the server clamps explicit creation sizes UP to per-type
|
|
197
214
|
readability floors (e.g. markdown 360×180, graph/json-render/html 420×280, mcp-app 480×320) —
|
|
198
215
|
a tiny probe size silently becomes the floor. `strictSize: true` is the only opt-out (a fixed
|
|
199
216
|
scrolling frame you genuinely want small). `canvas_query { action: "validate" }` additionally
|
|
@@ -258,6 +275,49 @@ or host actions.
|
|
|
258
275
|
Read [AX HTML control surfaces](references/ax-html-control-surface.md) before building an
|
|
259
276
|
interactive AX-enabled HTML node.
|
|
260
277
|
|
|
278
|
+
### Ready-made control surfaces (0.4.7+) — prefer these over hand-rolling
|
|
279
|
+
|
|
280
|
+
Two HTML primitives ship AX-enabled, so you do not need to author a control surface or pass
|
|
281
|
+
`axCapabilities` yourself. They are the ONLY primitives created with AX on; every other kind is
|
|
282
|
+
still a static document.
|
|
283
|
+
|
|
284
|
+
- **`ax-board`** — task list: create tasks, watch status change live, steer, bounded loop.
|
|
285
|
+
- **`ax-flow`** — the same controls drawn as a task flow with a loop-back rail, plus
|
|
286
|
+
**Materialize to board**.
|
|
287
|
+
|
|
288
|
+
**`ax.flow.materialize`** is the one interaction that creates canvas nodes, and it is deliberately
|
|
289
|
+
narrow: you supply TEXT ONLY (<= 12 steps, title <= 120 chars) and the server owns the result —
|
|
290
|
+
one `markdown` node per step, `flow` edges between them, a dashed `references` loop edge, and one
|
|
291
|
+
work item per step linked to its node. Re-materializing REPLACES the previous flow (the source
|
|
292
|
+
node keeps a manifest of what it created), so it is safe to call repeatedly.
|
|
293
|
+
|
|
294
|
+
**The flow also works without the panel.** Materialized step nodes carry `data.axStep`, and the
|
|
295
|
+
canvas renders native Start / Done / Blocked controls on them, with Run loop / Stop and a steer box
|
|
296
|
+
on the anchor (first) step. Those controls also follow the node into focus mode. The native loop
|
|
297
|
+
runs **server-side**: it advances when a step's work item completes, survives a browser reload, and
|
|
298
|
+
keeps going while the tab is closed — unlike the panel's loop, which dies with the iframe. Bounds
|
|
299
|
+
are the same either way: advances only while running, hard cap 20 runs, Stop persists immediately,
|
|
300
|
+
and `blocked`/`cancelled` halts it.
|
|
301
|
+
|
|
302
|
+
When you want the agent to work through a checklist the human can watch and steer, reach for
|
|
303
|
+
`ax-flow` + materialize rather than a markdown to-do list — the status chips and the loop come free.
|
|
304
|
+
|
|
305
|
+
### Working a flow: keep the board honest as you go
|
|
306
|
+
|
|
307
|
+
A materialized flow is only useful if it tracks reality. While you work one:
|
|
308
|
+
|
|
309
|
+
1. **Move the step to `in-progress` BEFORE you start it, and to `done` when it is actually done** —
|
|
310
|
+
not in a batch at the end. The human is watching the chips to know where you are; a board that
|
|
311
|
+
only updates on completion tells them nothing while it matters.
|
|
312
|
+
2. **Pin the in-progress step as context** (`canvas_pin_nodes` with just that node, or
|
|
313
|
+
`POST /api/canvas/context-pins` mode `set`). This makes the active step the one thing in
|
|
314
|
+
`canvas://pinned-context`, so the board's "what am I working on" and your own context are the
|
|
315
|
+
same answer instead of drifting apart. Re-pin as you advance.
|
|
316
|
+
3. **Mark `blocked` rather than going quiet** when you are stuck — a stalled `in-progress` chip is
|
|
317
|
+
indistinguishable from a crashed agent.
|
|
318
|
+
4. Read steering back with `canvas_ax_delivery` as you go; a human watching a live flow steers
|
|
319
|
+
mid-run, and a steer you never claim is a correction you ignored.
|
|
320
|
+
|
|
261
321
|
## Resources
|
|
262
322
|
|
|
263
323
|
Read the smallest resource that answers the question:
|
|
@@ -308,8 +368,8 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
308
368
|
- A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
|
|
309
369
|
upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
|
|
310
370
|
This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
|
|
311
|
-
background override ships but cannot reach the app's inner root). Keep ext-app tiles
|
|
312
|
-
|
|
371
|
+
background override ships but cannot reach the app's inner root). Keep ext-app tiles landscape —
|
|
372
|
+
at or above the 720x500 they open at, or the same ratio — or expand the node: the fullscreen
|
|
313
373
|
overlay renders full-bleed. The durable fix is upstream in the excalidraw-mcp app.
|
|
314
374
|
- Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
|
|
315
375
|
back from SSE to a polling transport within ~3s, so the board still boots and stays live.
|
|
@@ -327,10 +387,11 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
327
387
|
URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
|
|
328
388
|
`/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
|
|
329
389
|
- Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
|
|
330
|
-
reflow on a live window resize
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
390
|
+
reflow on a live window resize. Some single-tab host browsers historically didn't deliver
|
|
391
|
+
live-resize events; current Codex builds do — the 0.4.6 pass watched a standalone graph reflow
|
|
392
|
+
live (SVG 1550×783 → 850×483 on a 1600×900 → 900×600 window resize) with no reload. Do not
|
|
393
|
+
present a system browser as a workaround for stale resizing; it is only a preference for viewing
|
|
394
|
+
a full page beside the canvas.
|
|
334
395
|
- Some hosts cannot automate inside sandboxed workbench iframes. Verify those interactions in a
|
|
335
396
|
system browser or through server-side AX state.
|
|
336
397
|
- `pmx-canvas screenshot` requires an active WebView. Start it with
|
|
@@ -417,7 +417,7 @@
|
|
|
417
417
|
"id": 17,
|
|
418
418
|
"name": "standalone-surface-open-as-site",
|
|
419
419
|
"prompt": "Open the bar-chart graph node as a standalone full-page site for a screenshot, and also try to open the Excalidraw diagram node the same way. Tell me the correct surface URL for each.",
|
|
420
|
-
"expected_output": "For the graph/json-render node, uses the stable surface URL /api/canvas/surface/<id> which redirects to the full-viewport display=site viewer (reads surfaceUrl from the node, does not fabricate a path). Recognizes the hosted Excalidraw/ext-app node is NOT an open-as-site target (its surface route returns a clean 404 \u2014 it renders only with the in-canvas host bridge) and does not claim a standalone tab will work.
|
|
420
|
+
"expected_output": "For the graph/json-render node, uses the stable surface URL /api/canvas/surface/<id> which redirects to the full-viewport display=site viewer (reads surfaceUrl from the node, does not fabricate a path). Recognizes the hosted Excalidraw/ext-app node is NOT an open-as-site target (its surface route returns a clean 404 \u2014 it renders only with the in-canvas host bridge, and open-external refuses it with the same message) and does not claim a standalone tab will work. Describes the standalone surface as reflowing live on a window resize; does not claim host browsers leave a resized chart stale until reload, and does not prescribe a system browser as a workaround.",
|
|
421
421
|
"assertions": [
|
|
422
422
|
{
|
|
423
423
|
"name": "graph-site-surface-url",
|
|
@@ -430,8 +430,59 @@
|
|
|
430
430
|
"type": "output_check"
|
|
431
431
|
},
|
|
432
432
|
{
|
|
433
|
-
"name": "
|
|
434
|
-
"description": "
|
|
433
|
+
"name": "no-stale-resize-claim",
|
|
434
|
+
"description": "Does not assert that host browsers fail to deliver live resize (current Codex builds do — the 0.4.6 pass saw a standalone graph reflow live with no reload); a system browser may be offered as a viewing preference, never as a resize workaround",
|
|
435
|
+
"type": "output_check"
|
|
436
|
+
}
|
|
437
|
+
]
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
"id": 18,
|
|
441
|
+
"name": "in-view-placement-after-create",
|
|
442
|
+
"prompt": "The board already has a cluster of nodes from this morning, way off to the far right. Add a markdown summary of the incident and a status node for the follow-up, and make sure I can actually see them.",
|
|
443
|
+
"expected_output": "Places the new nodes near the human's attention (pinned/focused neighborhood or beside the last user-facing output) instead of trusting board-relative auto-placement on a spread-out board, then pans the camera: canvas_view {action:focus} for a single node, or canvas_view {action:fit, nodeIds:[...]} with exactly the new ids for the pair. Does not hand-compute the whole board's coordinates and does not fit the whole board to 'show' the new work — an outlier cluster would miniaturize everything.",
|
|
444
|
+
"assertions": [
|
|
445
|
+
{
|
|
446
|
+
"name": "ends-with-focus-or-fit",
|
|
447
|
+
"description": "Finishes with canvas_view {action:focus} (single node) or {action:fit} after creating the nodes, rather than leaving them off-camera",
|
|
448
|
+
"type": "output_check"
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
"name": "fit-targets-new-ids-only",
|
|
452
|
+
"description": "Passes exactly the new node ids to fit instead of fitting the whole board (a distant cluster would shrink everything)",
|
|
453
|
+
"type": "output_check"
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"name": "no-whole-board-coordinate-math",
|
|
457
|
+
"description": "Places relative to the human's attention or uses create-then-arrange; does not hand-compute a full board layout in pixels",
|
|
458
|
+
"type": "output_check"
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"id": 19,
|
|
464
|
+
"name": "node-size-discipline",
|
|
465
|
+
"prompt": "Add a markdown write-up of the outage and a bar chart of the error counts. Keep them tight — I don't want huge tiles.",
|
|
466
|
+
"expected_output": "Omits width/height and takes the per-type defaults (markdown 640×420, graph 760×520) rather than passing small probe sizes for human-readable content. Uses strictSize:true only for a deliberately fixed small frame, not to dodge the floor. Reads the size back from the create result or the layout before any layout math, because the server clamps an undersized explicit frame UP to the per-type floor (markdown 360×180, graph 420×280) — the returned size can differ from what was requested.",
|
|
467
|
+
"assertions": [
|
|
468
|
+
{
|
|
469
|
+
"name": "uses-type-defaults",
|
|
470
|
+
"description": "Omits width/height (or passes a readable size) instead of guessing a frame for readable content",
|
|
471
|
+
"type": "output_check"
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
"name": "no-tiny-probe-sizes",
|
|
475
|
+
"description": "Does not create human-readable nodes at tiny probe sizes that the floor would silently raise",
|
|
476
|
+
"type": "output_check"
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
"name": "strict-size-only-when-deliberate",
|
|
480
|
+
"description": "Reaches for strictSize:true only for an intentionally fixed small frame, not as a way around the readability floor",
|
|
481
|
+
"type": "output_check"
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
"name": "reads-back-created-size",
|
|
485
|
+
"description": "Reads the actual created size from the create result / canvas_query layout before positioning or sizing anything else, rather than assuming the requested size was kept",
|
|
435
486
|
"type": "output_check"
|
|
436
487
|
}
|
|
437
488
|
]
|
|
@@ -952,12 +952,15 @@ the host's embedded browser (e.g. Codex) opens `_blank` tabs in-place.
|
|
|
952
952
|
renders with the in-canvas host bridge — a bare tab has no peer, so the surface route
|
|
953
953
|
returns `404` ("This MCP app renders in the canvas and cannot be opened as a standalone
|
|
954
954
|
site"); view/edit them in the canvas, or open them externally through their own app
|
|
955
|
-
(report #61).
|
|
956
|
-
|
|
955
|
+
(report #61). `POST /api/canvas/open-external` refuses those nodes with the same message
|
|
956
|
+
instead of launching a browser at a URL that 404s. Only bundled `web-artifact` apps
|
|
957
|
+
(redirect to `/artifact`) and URL-backed `mcp-app` / `webpage` viewers redirect to their
|
|
958
|
+
external site.
|
|
957
959
|
- `graph` / `json-render` nodes redirect to the full-viewport `display=site` viewer; the chart
|
|
958
|
-
fills the window and reflows on a live resize
|
|
959
|
-
|
|
960
|
-
|
|
960
|
+
fills the window and reflows on a live resize. Current Codex builds deliver live-resize events —
|
|
961
|
+
the 0.4.6 pass watched a standalone graph reflow live (SVG 1550×783 → 850×483 on a
|
|
962
|
+
1600×900 → 900×600 window resize) with no reload — so do not present a system browser as a
|
|
963
|
+
workaround for stale resizing (report #67).
|
|
961
964
|
- This is additive — opening a site never evicts or replaces canvas nodes.
|
|
962
965
|
|
|
963
966
|
### Choosing the Right Visual Tier
|
|
@@ -1112,7 +1115,7 @@ AX interactions are gated per node type. The lists below are each type's **ceili
|
|
|
1112
1115
|
|
|
1113
1116
|
| Node type | Allowed AX interaction types |
|
|
1114
1117
|
|-----------|------------------------------|
|
|
1115
|
-
| `markdown` | `ax.steer`, `ax.work.create`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
|
|
1118
|
+
| `markdown` | `ax.steer`, `ax.work.create`, `ax.work.update`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
|
|
1116
1119
|
| `context` | `ax.focus.set`, `ax.steer`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
|
|
1117
1120
|
| `status` | `ax.work.create`, `ax.work.update`, `ax.approval.request`, `ax.mode.request`, `ax.event.record` |
|
|
1118
1121
|
| `file` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
|
|
@@ -1121,6 +1124,8 @@ AX interactions are gated per node type. The lists below are each type's **ceili
|
|
|
1121
1124
|
| `ledger` | `ax.evidence.add`, `ax.event.record` |
|
|
1122
1125
|
| `trace` | `ax.evidence.add`, `ax.event.record` |
|
|
1123
1126
|
| `image` | `ax.evidence.add`, `ax.review.add` |
|
|
1127
|
+
| `diff` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
|
|
1128
|
+
| `mermaid` | `ax.steer`, `ax.work.create`, `ax.evidence.add`, `ax.command.invoke`, `ax.event.record` |
|
|
1124
1129
|
| `webpage` | `ax.evidence.add`, `ax.review.add`, `ax.focus.set`, `ax.event.record` |
|
|
1125
1130
|
| `group` | `ax.focus.set`, `ax.work.create`, `ax.command.invoke`, `ax.event.record` |
|
|
1126
1131
|
|
|
@@ -81,7 +81,7 @@ cmd(
|
|
|
81
81
|
'Show canvas mutation history',
|
|
82
82
|
['pmx-canvas history', 'pmx-canvas history --summary', 'pmx-canvas history --compact'],
|
|
83
83
|
async (args) => {
|
|
84
|
-
const { flags } = parseFlags(args);
|
|
84
|
+
const { flags } = parseFlags(args, { boolFlags: ['summary'] });
|
|
85
85
|
if (flags.help || flags.h) return showCommandHelp('history');
|
|
86
86
|
|
|
87
87
|
const result = (await invokeOperation('history.get', {})) as Record<string, unknown>;
|
|
@@ -691,7 +691,7 @@ cmd(
|
|
|
691
691
|
'pmx-canvas node schema --summary',
|
|
692
692
|
],
|
|
693
693
|
async (args) => {
|
|
694
|
-
const { flags } = parseFlags(args);
|
|
694
|
+
const { flags } = parseFlags(args, { boolFlags: ['summary'] });
|
|
695
695
|
if (flags.help || flags.h) return showCommandHelp('node schema');
|
|
696
696
|
|
|
697
697
|
const result = await loadCanvasSchema();
|
|
@@ -783,7 +783,7 @@ cmd(
|
|
|
783
783
|
'pmx-canvas node get node-abc123 --field title --field graphConfig',
|
|
784
784
|
],
|
|
785
785
|
async (args) => {
|
|
786
|
-
const { positional, flags } = parseFlags(args);
|
|
786
|
+
const { positional, flags } = parseFlags(args, { boolFlags: ['summary'] });
|
|
787
787
|
if (flags.help || flags.h) return showCommandHelp('node get');
|
|
788
788
|
|
|
789
789
|
const id = positional[0];
|
|
@@ -65,7 +65,7 @@ cmd(
|
|
|
65
65
|
'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary',
|
|
66
66
|
],
|
|
67
67
|
async (args) => {
|
|
68
|
-
const { flags } = parseFlags(args);
|
|
68
|
+
const { flags } = parseFlags(args, { boolFlags: ['summary'] });
|
|
69
69
|
if (flags.help || flags.h) return showCommandHelp('validate spec');
|
|
70
70
|
|
|
71
71
|
const type = getStringFlag(flags, 'type');
|
package/src/cli/commands/view.ts
CHANGED
|
@@ -44,7 +44,7 @@ cmd(
|
|
|
44
44
|
'Get the full canvas layout (nodes, edges, viewport)',
|
|
45
45
|
['pmx-canvas layout', 'pmx-canvas layout --summary'],
|
|
46
46
|
async (args) => {
|
|
47
|
-
const { flags } = parseFlags(args);
|
|
47
|
+
const { flags } = parseFlags(args, { boolFlags: ['summary'] });
|
|
48
48
|
if (flags.help || flags.h) return showCommandHelp('layout');
|
|
49
49
|
|
|
50
50
|
if (flags.summary || flags.compact) {
|
package/src/cli/index.ts
CHANGED
|
@@ -298,19 +298,25 @@ Agent CLI (works against running server):
|
|
|
298
298
|
validate spec Validate json-render/graph payloads without creating nodes
|
|
299
299
|
watch [--json] [--events ...] Watch low-token semantic canvas changes
|
|
300
300
|
focus <node-id> Pan to node
|
|
301
|
+
fit [node-id ...] Fit the viewport to all nodes or a subset
|
|
301
302
|
ax status|context|focus Inspect AX state and focus
|
|
302
303
|
external-app add Add hosted external apps like Excalidraw
|
|
303
304
|
diagram add Add an Excalidraw diagram node
|
|
305
|
+
html primitive add|schema Add or inspect HTML communication primitives
|
|
304
306
|
pin <ids...> | --list | --clear Manage context pins
|
|
305
307
|
undo / redo / history Time travel
|
|
306
308
|
snapshot save|list|restore|diff|delete
|
|
307
309
|
Manage snapshots
|
|
310
|
+
diff <snapshot-id> Compare the canvas against a snapshot
|
|
308
311
|
group create|add|remove Manage groups
|
|
309
312
|
web-artifact build Build bundled web artifacts
|
|
310
313
|
clear --yes Clear canvas
|
|
311
314
|
code-graph File dependencies
|
|
312
315
|
spatial Spatial analysis
|
|
313
316
|
watch Semantic watch stream
|
|
317
|
+
smoke Check that a running canvas works here
|
|
318
|
+
skills sync Sync installed copies of the bundled skills
|
|
319
|
+
copilot install-extension Install the GitHub Copilot app extension
|
|
314
320
|
|
|
315
321
|
Run any command with --help for details and examples:
|
|
316
322
|
pmx-canvas node add --help
|
package/src/cli/shared.ts
CHANGED
|
@@ -47,6 +47,13 @@ export function extractGlobalTargetFlags(args: string[]): string[] {
|
|
|
47
47
|
const arg = args[i];
|
|
48
48
|
let name: 'port' | 'server-url' | null = null;
|
|
49
49
|
let value: string | undefined;
|
|
50
|
+
if (arg === '--') {
|
|
51
|
+
// End of flags: everything after a bare `--` is an operand, so a literal
|
|
52
|
+
// `--port`-looking value is never mistaken for the global target flag.
|
|
53
|
+
// The separator itself is kept so parseFlags stops there too.
|
|
54
|
+
rest.push(...args.slice(i));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
50
57
|
if (arg === '--port' || arg === '--server-url') {
|
|
51
58
|
name = arg.slice(2) as 'port' | 'server-url';
|
|
52
59
|
value = args[i + 1];
|
|
@@ -125,11 +132,25 @@ async function invokeOperation(name: string, input: Record<string, unknown>): Pr
|
|
|
125
132
|
|
|
126
133
|
// ── Flag parsing ─────────────────────────────────────────────
|
|
127
134
|
|
|
128
|
-
|
|
135
|
+
/**
|
|
136
|
+
* `options.boolFlags` marks flags that are boolean FOR THIS COMMAND. Value
|
|
137
|
+
* flags consume the next token verbatim (so a unified diff beginning with
|
|
138
|
+
* `--- a/file` survives), which makes a flag that is boolean here and
|
|
139
|
+
* value-taking elsewhere ambiguous — `--summary` is boolean in `history` /
|
|
140
|
+
* `layout` / `node get` / `validate spec` but carries text in `node add`.
|
|
141
|
+
* Without this per-command override, `history --summary --limit 5` would
|
|
142
|
+
* swallow `--limit` as the summary's value. The global set below holds flags
|
|
143
|
+
* that are boolean everywhere.
|
|
144
|
+
*/
|
|
145
|
+
function parseFlags(
|
|
146
|
+
args: string[],
|
|
147
|
+
options: { boolFlags?: readonly string[] } = {},
|
|
148
|
+
): { positional: string[]; flags: Record<string, string | true> } {
|
|
129
149
|
const positional: string[] = [];
|
|
130
150
|
const flags: Record<string, string | true> = {};
|
|
131
151
|
// Boolean-only flags (never take a value argument)
|
|
132
|
-
const BOOL_FLAGS = new Set([
|
|
152
|
+
const BOOL_FLAGS = new Set<string>([
|
|
153
|
+
...(options.boolFlags ?? []),
|
|
133
154
|
'help',
|
|
134
155
|
'h',
|
|
135
156
|
'ids',
|
|
@@ -168,14 +189,22 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
|
|
|
168
189
|
]);
|
|
169
190
|
for (let i = 0; i < args.length; i++) {
|
|
170
191
|
const arg = args[i];
|
|
192
|
+
// Conventional end-of-flags separator: the rest is positional, never flags.
|
|
193
|
+
if (arg === '--') {
|
|
194
|
+
positional.push(...args.slice(i + 1));
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
171
197
|
if (arg.startsWith('--')) {
|
|
172
198
|
const eq = arg.indexOf('=');
|
|
173
199
|
if (eq !== -1) {
|
|
174
200
|
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
175
201
|
} else {
|
|
176
202
|
const key = arg.slice(2);
|
|
177
|
-
//
|
|
178
|
-
|
|
203
|
+
// A value flag consumes the very next token VERBATIM — including one
|
|
204
|
+
// that starts with `-`/`--`. Unified diffs legitimately begin with
|
|
205
|
+
// `--- a/file`, and the old "next token must not look like a flag"
|
|
206
|
+
// heuristic silently dropped that content.
|
|
207
|
+
if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
|
|
179
208
|
flags[key] = args[++i];
|
|
180
209
|
} else {
|
|
181
210
|
flags[key] = true;
|
|
@@ -183,7 +212,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
|
|
|
183
212
|
}
|
|
184
213
|
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
185
214
|
const key = arg.slice(1);
|
|
186
|
-
if (!BOOL_FLAGS.has(key) && i + 1 < args.length
|
|
215
|
+
if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
|
|
187
216
|
flags[key] = args[++i];
|
|
188
217
|
} else {
|
|
189
218
|
flags[key] = true;
|
package/src/client/App.tsx
CHANGED
|
@@ -36,10 +36,11 @@ import {
|
|
|
36
36
|
traceEnabled,
|
|
37
37
|
viewport,
|
|
38
38
|
walkGraph,
|
|
39
|
+
zoomByFactor,
|
|
39
40
|
} from './state/canvas-store';
|
|
40
41
|
import { connectSSE } from './state/sse-bridge';
|
|
41
42
|
import { intents } from './state/intent-store';
|
|
42
|
-
import { saveCanvasTheme } from './state/intent-bridge';
|
|
43
|
+
import { reportClientViewportSize, saveCanvasTheme } from './state/intent-bridge';
|
|
43
44
|
import {
|
|
44
45
|
IconArrange,
|
|
45
46
|
IconClearTrace,
|
|
@@ -258,20 +259,12 @@ function Toolbar({
|
|
|
258
259
|
</button>
|
|
259
260
|
</ToolbarHint>
|
|
260
261
|
<ToolbarHint label="Zoom in" shortcut={`${MOD_KEY}++`}>
|
|
261
|
-
<button
|
|
262
|
-
type="button"
|
|
263
|
-
onClick={() => animateViewport({ ...v, scale: Math.min(4, v.scale * 1.25) }, 150)}
|
|
264
|
-
aria-label="Zoom in"
|
|
265
|
-
>
|
|
262
|
+
<button type="button" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
|
|
266
263
|
<IconZoomIn />
|
|
267
264
|
</button>
|
|
268
265
|
</ToolbarHint>
|
|
269
266
|
<ToolbarHint label="Zoom out" shortcut={`${MOD_KEY}+-`}>
|
|
270
|
-
<button
|
|
271
|
-
type="button"
|
|
272
|
-
onClick={() => animateViewport({ ...v, scale: Math.max(0.1, v.scale / 1.25) }, 150)}
|
|
273
|
-
aria-label="Zoom out"
|
|
274
|
-
>
|
|
267
|
+
<button type="button" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
|
|
275
268
|
<IconZoomOut />
|
|
276
269
|
</button>
|
|
277
270
|
</ToolbarHint>
|
|
@@ -585,6 +578,25 @@ export function App() {
|
|
|
585
578
|
return connectSSE();
|
|
586
579
|
}, []);
|
|
587
580
|
|
|
581
|
+
// Keep the server's idea of this window's size current (0.4.6 orb feedback
|
|
582
|
+
// #2) so an agent `fit` computes a scale that actually fits the human's
|
|
583
|
+
// window. Connect-time reporting lives in the SSE bridge; this covers resizes.
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
let timer: number | null = null;
|
|
586
|
+
const schedule = () => {
|
|
587
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
588
|
+
timer = window.setTimeout(() => {
|
|
589
|
+
timer = null;
|
|
590
|
+
void reportClientViewportSize();
|
|
591
|
+
}, 400);
|
|
592
|
+
};
|
|
593
|
+
window.addEventListener('resize', schedule);
|
|
594
|
+
return () => {
|
|
595
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
596
|
+
window.removeEventListener('resize', schedule);
|
|
597
|
+
};
|
|
598
|
+
}, []);
|
|
599
|
+
|
|
588
600
|
useEffect(() => {
|
|
589
601
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
590
602
|
const mod = e.metaKey || e.ctrlKey;
|
|
@@ -640,12 +652,13 @@ export function App() {
|
|
|
640
652
|
animateViewport({ x: 0, y: 0, scale: 1 }, 250);
|
|
641
653
|
} else if (mod && (e.key === '=' || e.key === '+')) {
|
|
642
654
|
e.preventDefault();
|
|
643
|
-
|
|
644
|
-
|
|
655
|
+
// Same centre-anchored zoom as the toolbar +/- buttons these shortcuts
|
|
656
|
+
// are advertised on (the tooltip names this key) — a raw scale change
|
|
657
|
+
// is origin-anchored and drifts the board away under the cursor.
|
|
658
|
+
zoomByFactor(1.25);
|
|
645
659
|
} else if (mod && e.key === '-') {
|
|
646
660
|
e.preventDefault();
|
|
647
|
-
|
|
648
|
-
animateViewport({ ...cur, scale: Math.max(0.1, cur.scale / 1.25) }, 150);
|
|
661
|
+
zoomByFactor(1 / 1.25);
|
|
649
662
|
} else if (e.key === 'Escape') {
|
|
650
663
|
if (selectedNodeIds.value.size > 0) {
|
|
651
664
|
clearSelection();
|
|
@@ -21,15 +21,54 @@ import {
|
|
|
21
21
|
viewport,
|
|
22
22
|
} from '../state/canvas-store';
|
|
23
23
|
import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
|
|
24
|
+
import { AxStepControls } from '../nodes/AxStepControls';
|
|
24
25
|
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
25
26
|
import { getNodeIcon } from '../icons';
|
|
26
27
|
import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
|
|
27
28
|
import type { CanvasNodeState } from '../types';
|
|
28
|
-
import { computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
|
|
29
|
+
import { AUTO_FIT_TITLEBAR_HEIGHT, computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
|
|
29
30
|
import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './snap-guides';
|
|
30
31
|
import { useNodeDrag } from './use-node-drag';
|
|
31
32
|
import { useNodeResize } from './use-node-resize';
|
|
32
33
|
|
|
34
|
+
/** Fraction of a node's height the title bar may occupy before it starts
|
|
35
|
+
* crowding out the content it is labelling. */
|
|
36
|
+
const MAX_TITLEBAR_HEIGHT_RATIO = 0.4;
|
|
37
|
+
/** Fraction of a node's width the fixed title-bar chrome may occupy before it
|
|
38
|
+
* starts eating the node's NAME. */
|
|
39
|
+
const MAX_TITLEBAR_CHROME_WIDTH_RATIO = 0.55;
|
|
40
|
+
/** Unscaled width of everything in the bar that is not the title: type icon,
|
|
41
|
+
* status/type pill, the control cluster, and the gaps between them. */
|
|
42
|
+
const TITLEBAR_CHROME_BASE_WIDTH = 175;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How much to enlarge node chrome (title bar, badges, icons) when zoomed out so
|
|
46
|
+
* it stays legible. Full inverse compensation is capped at 2.2x — but the cap
|
|
47
|
+
* alone ignored the node it is drawn on, so on SHORT nodes the growing bar ate
|
|
48
|
+
* the body: a 116px-tall section label at 46% zoom gave the title bar 72% of the
|
|
49
|
+
* node and left 14px for text that needed 41, so the markdown was simply cut off.
|
|
50
|
+
* The scale is therefore also bounded by the node's own height, keeping the bar
|
|
51
|
+
* under MAX_TITLEBAR_HEIGHT_RATIO of it. Tall nodes are unaffected (their height
|
|
52
|
+
* never binds); pass height 0 for collapsed/auto-height nodes to skip the bound.
|
|
53
|
+
*/
|
|
54
|
+
export function nodeChromeScale(viewportScale: number, nodeHeight: number, nodeWidth = 0): number {
|
|
55
|
+
if (!Number.isFinite(viewportScale) || viewportScale >= 1) return 1;
|
|
56
|
+
const zoomScale = Math.min(2.2, 1 / Math.max(viewportScale, 0.01));
|
|
57
|
+
let bounded = zoomScale;
|
|
58
|
+
if (Number.isFinite(nodeHeight) && nodeHeight > 0) {
|
|
59
|
+
bounded = Math.min(bounded, (nodeHeight * MAX_TITLEBAR_HEIGHT_RATIO) / AUTO_FIT_TITLEBAR_HEIGHT);
|
|
60
|
+
}
|
|
61
|
+
// Width matters as much as height: the icon, pill and control cluster are all
|
|
62
|
+
// flex-shrink: 0, so the NAME is the only thing that gives way. Unbounded, a
|
|
63
|
+
// 360px step node at 52% zoom spent 100 of 187 bar pixels on controls alone and
|
|
64
|
+
// the title collapsed to nothing. Bound the scale so the fixed chrome can never
|
|
65
|
+
// take more than MAX_TITLEBAR_CHROME_WIDTH_RATIO of the node.
|
|
66
|
+
if (Number.isFinite(nodeWidth) && nodeWidth > 0) {
|
|
67
|
+
bounded = Math.min(bounded, (nodeWidth * MAX_TITLEBAR_CHROME_WIDTH_RATIO) / TITLEBAR_CHROME_BASE_WIDTH);
|
|
68
|
+
}
|
|
69
|
+
return Math.max(1, bounded);
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
interface CanvasNodeProps {
|
|
34
73
|
node: CanvasNodeState;
|
|
35
74
|
children: preact.ComponentChildren;
|
|
@@ -220,7 +259,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
220
259
|
const isGroup = node.type === 'group';
|
|
221
260
|
const isStrictSize = node.data.strictSize === true;
|
|
222
261
|
const viewportScale = Math.max(viewport.value.scale, 0.01);
|
|
223
|
-
const chromeScale = viewportScale
|
|
262
|
+
const chromeScale = nodeChromeScale(viewportScale, node.collapsed ? 0 : node.size.height, node.size.width);
|
|
224
263
|
|
|
225
264
|
const groupColor = isGroup && node.data.color ? (node.data.color as string) : undefined;
|
|
226
265
|
const nodeStyle: Record<string, string | number> = {
|
|
@@ -261,9 +300,13 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
261
300
|
return <NodeIcon size={Math.round(14 * chromeScale)} />;
|
|
262
301
|
})()}
|
|
263
302
|
</span>
|
|
264
|
-
|
|
265
|
-
|
|
303
|
+
{/* The icon already says what type this is, so the type badge yields to
|
|
304
|
+
the status chip rather than stacking two pills in front of the name —
|
|
305
|
+
on a narrow node that combination pushed the title to an ellipsis. */}
|
|
306
|
+
{typeof node.data.axWorkStatus === 'string' ? (
|
|
266
307
|
<span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
|
|
308
|
+
) : (
|
|
309
|
+
<span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
|
|
267
310
|
)}
|
|
268
311
|
{renaming ? (
|
|
269
312
|
<input
|
|
@@ -278,7 +321,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
278
321
|
onClick={(e) => e.stopPropagation()}
|
|
279
322
|
/>
|
|
280
323
|
) : (
|
|
281
|
-
<span class="node-title" onDblClick={handleTitleDblClick} title=
|
|
324
|
+
<span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
|
|
282
325
|
{title}
|
|
283
326
|
</span>
|
|
284
327
|
)}
|
|
@@ -356,6 +399,12 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
356
399
|
{!node.collapsed && (
|
|
357
400
|
<div ref={bodyRef} class="node-body">
|
|
358
401
|
{children}
|
|
402
|
+
{/* AX flow step controls — rendered here rather than in a node renderer
|
|
403
|
+
because a materialized step can be ANY node type, and because the
|
|
404
|
+
body is what auto-fit measures (a footer outside it would be clipped
|
|
405
|
+
on a content-fitted node). Renders nothing unless the node carries a
|
|
406
|
+
`data.axStep` stamp. */}
|
|
407
|
+
<AxStepControls node={node} />
|
|
359
408
|
</div>
|
|
360
409
|
)}
|
|
361
410
|
{!node.collapsed && (
|