pmx-canvas 0.4.5 → 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 +118 -0
- package/Readme.md +76 -36
- package/dist/canvas/global.css +169 -3
- package/dist/canvas/index.js +112 -87
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- 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/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -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/client/types.d.ts +2 -2
- 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-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +18 -1
- package/dist/types/server/canvas-validation.d.ts +28 -1
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +31 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +29 -0
- package/docs/mcp.md +13 -1
- package/docs/node-types.md +47 -1
- package/docs/screenshot.png +0 -0
- package/package.json +5 -4
- package/skills/pmx-canvas/SKILL.md +123 -25
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- 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/smoke.ts +212 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/shared.ts +35 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +12 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +32 -1
- package/src/client/theme/global.css +169 -3
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +305 -4
- package/src/server/canvas-operations.ts +83 -26
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +46 -10
- package/src/server/canvas-validation.ts +88 -0
- 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/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +15 -2
- package/src/server/server.ts +154 -6
- package/src/server/web-artifacts.ts +109 -0
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/node-sizes.ts +52 -0
- package/src/shared/surface.ts +2 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// One-command environment check: does a running canvas actually work here?
|
|
2
|
+
//
|
|
3
|
+
// Runs the checks every host test report hand-rolls — health + workspace,
|
|
4
|
+
// MCP stdio initialize, a temp-node create/search/remove round-trip, and
|
|
5
|
+
// structural board validation — and reports them as a single JSON result
|
|
6
|
+
// with a pass/fail exit code. Designed for fresh orbs/hosts: `pmx-canvas
|
|
7
|
+
// smoke` answers "does the canvas work in this environment?" in one call.
|
|
8
|
+
//
|
|
9
|
+
// The command tests a RUNNING server (like every other agent command); it
|
|
10
|
+
// never starts one. Board validation gates only on structural issues
|
|
11
|
+
// (missing edge endpoints) — node overlaps are a layout choice on real
|
|
12
|
+
// boards, so collisions are reported but never fail the smoke.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { HttpOperationInvoker } from '../../server/operations/index.js';
|
|
18
|
+
import { cmd, getBaseUrl, isRecord, output, parseFlags, showCommandHelp } from '../shared.js';
|
|
19
|
+
|
|
20
|
+
interface SmokeCheck {
|
|
21
|
+
name: string;
|
|
22
|
+
ok: boolean;
|
|
23
|
+
detail: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MCP_INITIALIZE_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
function cliPackageVersion(): string {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8')) as {
|
|
31
|
+
version?: string;
|
|
32
|
+
};
|
|
33
|
+
return pkg.version ?? 'unknown';
|
|
34
|
+
} catch {
|
|
35
|
+
return 'unknown';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function checkHealth(baseUrl: string, cliVersion: string): Promise<SmokeCheck & { serverVersion?: string }> {
|
|
40
|
+
const name = 'health';
|
|
41
|
+
let body: unknown;
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`${baseUrl}/health`);
|
|
44
|
+
if (!response.ok) return { name, ok: false, detail: `GET /health returned HTTP ${response.status}` };
|
|
45
|
+
body = await response.json();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
ok: false,
|
|
50
|
+
detail: `Cannot connect to pmx-canvas at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}. Start the server first: pmx-canvas --no-open`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (!isRecord(body) || body.ok !== true) {
|
|
54
|
+
return { name, ok: false, detail: `GET /health returned an unhealthy body: ${JSON.stringify(body)}` };
|
|
55
|
+
}
|
|
56
|
+
const serverVersion = typeof body.version === 'string' ? body.version : 'unknown';
|
|
57
|
+
const persistence = isRecord(body.persistence) && body.persistence.ok === false ? 'DEGRADED' : 'ok';
|
|
58
|
+
const skew =
|
|
59
|
+
serverVersion !== 'unknown' && serverVersion !== cliVersion
|
|
60
|
+
? ` — differs from CLI v${cliVersion} (version skew)`
|
|
61
|
+
: '';
|
|
62
|
+
return {
|
|
63
|
+
name,
|
|
64
|
+
ok: true,
|
|
65
|
+
serverVersion,
|
|
66
|
+
detail: `workspace ${String(body.workspace)}, server v${serverVersion}${skew}, pid ${String(body.pid)}, persistence ${persistence}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Spawn the bundled MCP server over stdio (same entry `pmx-canvas --mcp`
|
|
72
|
+
* runs) and verify a JSON-RPC initialize handshake completes. The transport
|
|
73
|
+
* is newline-delimited JSON; the child is killed as soon as the response
|
|
74
|
+
* with our request id arrives.
|
|
75
|
+
*/
|
|
76
|
+
function checkMcpInitialize(cliVersion: string): Promise<SmokeCheck> {
|
|
77
|
+
const name = 'mcp-initialize';
|
|
78
|
+
const entry = fileURLToPath(new URL('../../mcp/server.ts', import.meta.url));
|
|
79
|
+
return new Promise((resolveCheck) => {
|
|
80
|
+
const child = spawn(process.execPath, ['run', entry], {
|
|
81
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
82
|
+
env: process.env,
|
|
83
|
+
});
|
|
84
|
+
let settled = false;
|
|
85
|
+
let stdoutBuffer = '';
|
|
86
|
+
const finish = (ok: boolean, detail: string) => {
|
|
87
|
+
if (settled) return;
|
|
88
|
+
settled = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
child.kill();
|
|
91
|
+
resolveCheck({ name, ok, detail });
|
|
92
|
+
};
|
|
93
|
+
const timer = setTimeout(
|
|
94
|
+
() => finish(false, `no initialize response within ${MCP_INITIALIZE_TIMEOUT_MS}ms`),
|
|
95
|
+
MCP_INITIALIZE_TIMEOUT_MS,
|
|
96
|
+
);
|
|
97
|
+
child.on('error', (error) => finish(false, `failed to spawn MCP server: ${error.message}`));
|
|
98
|
+
child.on('exit', (code) => finish(false, `MCP server exited before responding (code ${code ?? 'unknown'})`));
|
|
99
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
100
|
+
stdoutBuffer += chunk.toString('utf-8');
|
|
101
|
+
const lines = stdoutBuffer.split('\n');
|
|
102
|
+
stdoutBuffer = lines.pop() ?? '';
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (!line.trim()) continue;
|
|
105
|
+
let message: unknown;
|
|
106
|
+
try {
|
|
107
|
+
message = JSON.parse(line);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!isRecord(message) || message.id !== 1) continue;
|
|
112
|
+
const result = isRecord(message.result) ? message.result : null;
|
|
113
|
+
const serverInfo = result && isRecord(result.serverInfo) ? result.serverInfo : null;
|
|
114
|
+
if (serverInfo) {
|
|
115
|
+
finish(true, `initialize ok: ${String(serverInfo.name)} v${String(serverInfo.version)}`);
|
|
116
|
+
} else {
|
|
117
|
+
finish(false, `initialize returned an error: ${JSON.stringify(message.error ?? message)}`);
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
child.stdin.write(
|
|
123
|
+
`${JSON.stringify({
|
|
124
|
+
jsonrpc: '2.0',
|
|
125
|
+
id: 1,
|
|
126
|
+
method: 'initialize',
|
|
127
|
+
params: {
|
|
128
|
+
protocolVersion: '2025-06-18',
|
|
129
|
+
capabilities: {},
|
|
130
|
+
clientInfo: { name: 'pmx-canvas-smoke', version: cliVersion },
|
|
131
|
+
},
|
|
132
|
+
})}\n`,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function checkNodeLifecycle(invoker: HttpOperationInvoker): Promise<SmokeCheck> {
|
|
138
|
+
const name = 'node-lifecycle';
|
|
139
|
+
const marker = `pmx-smoke-${Math.random().toString(36).slice(2, 10)}`;
|
|
140
|
+
const searchHits = async (): Promise<Set<string>> => {
|
|
141
|
+
const result = await invoker.invoke('search', { q: marker });
|
|
142
|
+
const results = isRecord(result) && Array.isArray(result.results) ? result.results : [];
|
|
143
|
+
return new Set(results.filter(isRecord).map((entry) => String(entry.id)));
|
|
144
|
+
};
|
|
145
|
+
let nodeId: string | null = null;
|
|
146
|
+
try {
|
|
147
|
+
const added = await invoker.invoke('node.add', {
|
|
148
|
+
type: 'markdown',
|
|
149
|
+
title: marker,
|
|
150
|
+
content: `Temporary smoke-check node (${marker}). Safe to remove.`,
|
|
151
|
+
x: 100_000,
|
|
152
|
+
y: 100_000,
|
|
153
|
+
});
|
|
154
|
+
const node = isRecord(added) && isRecord(added.node) ? added.node : null;
|
|
155
|
+
nodeId = node && typeof node.id === 'string' ? node.id : null;
|
|
156
|
+
if (!nodeId) return { name, ok: false, detail: `node.add returned no node id: ${JSON.stringify(added)}` };
|
|
157
|
+
const found = (await searchHits()).has(nodeId);
|
|
158
|
+
// Remove BEFORE reporting either way: an early return here (search miss —
|
|
159
|
+
// exactly the version-skew case this command exists to detect) would skip
|
|
160
|
+
// the catch-block cleanup and park the temp node on the user's real board.
|
|
161
|
+
await invoker.invoke('node.remove', { id: nodeId });
|
|
162
|
+
nodeId = null;
|
|
163
|
+
if (!found) return { name, ok: false, detail: 'search did not find the created node' };
|
|
164
|
+
return { name, ok: true, detail: 'create → search → remove round-trip ok' };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// Best-effort cleanup so a failed smoke never leaves its temp node behind.
|
|
167
|
+
if (nodeId) await invoker.invoke('node.remove', { id: nodeId }).catch(() => {});
|
|
168
|
+
return { name, ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function checkValidation(invoker: HttpOperationInvoker): Promise<SmokeCheck> {
|
|
173
|
+
const name = 'validate';
|
|
174
|
+
try {
|
|
175
|
+
const result = await invoker.invoke('validate.get', {});
|
|
176
|
+
const summary = isRecord(result) && isRecord(result.summary) ? result.summary : {};
|
|
177
|
+
const missingEdgeEndpoints = Number(summary.missingEdgeEndpoints ?? 0);
|
|
178
|
+
const detail = `${String(summary.nodes ?? '?')} nodes, ${String(summary.edges ?? '?')} edges, ${String(
|
|
179
|
+
summary.collisions ?? '?',
|
|
180
|
+
)} collisions (informational), ${missingEdgeEndpoints} broken edges`;
|
|
181
|
+
return { name, ok: missingEdgeEndpoints === 0, detail };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { name, ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
cmd(
|
|
188
|
+
'smoke',
|
|
189
|
+
'One-command check that a running canvas works in this environment',
|
|
190
|
+
['pmx-canvas smoke', 'pmx-canvas smoke --skip-mcp', 'pmx-canvas smoke --port 4750'],
|
|
191
|
+
async (args) => {
|
|
192
|
+
const { flags } = parseFlags(args);
|
|
193
|
+
if (flags.help || flags.h) return showCommandHelp('smoke');
|
|
194
|
+
|
|
195
|
+
const baseUrl = getBaseUrl();
|
|
196
|
+
const cliVersion = cliPackageVersion();
|
|
197
|
+
const invoker = new HttpOperationInvoker(baseUrl);
|
|
198
|
+
const checks: SmokeCheck[] = [];
|
|
199
|
+
|
|
200
|
+
const health = await checkHealth(baseUrl, cliVersion);
|
|
201
|
+
checks.push({ name: health.name, ok: health.ok, detail: health.detail });
|
|
202
|
+
if (health.ok) {
|
|
203
|
+
if (!flags['skip-mcp']) checks.push(await checkMcpInitialize(cliVersion));
|
|
204
|
+
checks.push(await checkNodeLifecycle(invoker));
|
|
205
|
+
checks.push(await checkValidation(invoker));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ok = checks.every((check) => check.ok);
|
|
209
|
+
output({ ok, target: baseUrl, cliVersion, serverVersion: health.serverVersion ?? null, checks });
|
|
210
|
+
if (!ok) process.exit(1);
|
|
211
|
+
},
|
|
212
|
+
);
|
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
|
@@ -67,6 +67,7 @@ const AGENT_COMMANDS = new Set([
|
|
|
67
67
|
'serve',
|
|
68
68
|
'copilot',
|
|
69
69
|
'skills',
|
|
70
|
+
'smoke',
|
|
70
71
|
]);
|
|
71
72
|
|
|
72
73
|
const firstArg = args[0] ?? '';
|
|
@@ -297,19 +298,25 @@ Agent CLI (works against running server):
|
|
|
297
298
|
validate spec Validate json-render/graph payloads without creating nodes
|
|
298
299
|
watch [--json] [--events ...] Watch low-token semantic canvas changes
|
|
299
300
|
focus <node-id> Pan to node
|
|
301
|
+
fit [node-id ...] Fit the viewport to all nodes or a subset
|
|
300
302
|
ax status|context|focus Inspect AX state and focus
|
|
301
303
|
external-app add Add hosted external apps like Excalidraw
|
|
302
304
|
diagram add Add an Excalidraw diagram node
|
|
305
|
+
html primitive add|schema Add or inspect HTML communication primitives
|
|
303
306
|
pin <ids...> | --list | --clear Manage context pins
|
|
304
307
|
undo / redo / history Time travel
|
|
305
308
|
snapshot save|list|restore|diff|delete
|
|
306
309
|
Manage snapshots
|
|
310
|
+
diff <snapshot-id> Compare the canvas against a snapshot
|
|
307
311
|
group create|add|remove Manage groups
|
|
308
312
|
web-artifact build Build bundled web artifacts
|
|
309
313
|
clear --yes Clear canvas
|
|
310
314
|
code-graph File dependencies
|
|
311
315
|
spatial Spatial analysis
|
|
312
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
|
|
313
320
|
|
|
314
321
|
Run any command with --help for details and examples:
|
|
315
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',
|
|
@@ -164,17 +185,26 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
|
|
|
164
185
|
'files',
|
|
165
186
|
'ui-prompts',
|
|
166
187
|
'check',
|
|
188
|
+
'skip-mcp',
|
|
167
189
|
]);
|
|
168
190
|
for (let i = 0; i < args.length; i++) {
|
|
169
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
|
+
}
|
|
170
197
|
if (arg.startsWith('--')) {
|
|
171
198
|
const eq = arg.indexOf('=');
|
|
172
199
|
if (eq !== -1) {
|
|
173
200
|
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
174
201
|
} else {
|
|
175
202
|
const key = arg.slice(2);
|
|
176
|
-
//
|
|
177
|
-
|
|
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) {
|
|
178
208
|
flags[key] = args[++i];
|
|
179
209
|
} else {
|
|
180
210
|
flags[key] = true;
|
|
@@ -182,7 +212,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
|
|
|
182
212
|
}
|
|
183
213
|
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
184
214
|
const key = arg.slice(1);
|
|
185
|
-
if (!BOOL_FLAGS.has(key) && i + 1 < args.length
|
|
215
|
+
if (!BOOL_FLAGS.has(key) && i + 1 < args.length) {
|
|
186
216
|
flags[key] = args[++i];
|
|
187
217
|
} else {
|
|
188
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 && (
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { ContextNode } from '../nodes/ContextNode';
|
|
3
|
+
import { DiffNode } from '../nodes/DiffNode';
|
|
3
4
|
import { FileNode } from '../nodes/FileNode';
|
|
4
5
|
import { LedgerNode } from '../nodes/LedgerNode';
|
|
5
6
|
import { MarkdownNode } from '../nodes/MarkdownNode';
|
|
7
|
+
import { MermaidNode } from '../nodes/MermaidNode';
|
|
6
8
|
import { McpAppNode } from '../nodes/McpAppNode';
|
|
7
9
|
import { StatusNode } from '../nodes/StatusNode';
|
|
8
10
|
import { ImageNode } from '../nodes/ImageNode';
|
|
@@ -64,6 +66,10 @@ function renderNodeContent(node: CanvasNodeState) {
|
|
|
64
66
|
return <TraceNode node={node} />;
|
|
65
67
|
case 'file':
|
|
66
68
|
return <FileNode node={node} />;
|
|
69
|
+
case 'diff':
|
|
70
|
+
return <DiffNode node={node} />;
|
|
71
|
+
case 'mermaid':
|
|
72
|
+
return <MermaidNode node={node} />;
|
|
67
73
|
case 'image':
|
|
68
74
|
return <ImageNode node={node} />;
|
|
69
75
|
case 'html':
|