omkit 0.8.10 → 0.8.12

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/README.md CHANGED
@@ -185,11 +185,11 @@ flakyBackgroundJob().result.catch((e) => console.warn("job failed, carrying on",
185
185
 
186
186
  Every action's output, events, asserts, snapshots, and lifecycle land on **one timeline** — streamed live to the terminal (curated milestones) and written to a per-run folder:
187
187
 
188
- - `result.json` — the run's tree, per-node status, verdict, and curated artifacts.
188
+ - `result.json` — the run's tree, per-node status, verdict, and curated artifacts. Each node also carries what its definition declared about itself: the `.describe()` summary, the `.mcp()` exposure, and the `.args()` schema as JSON Schema.
189
189
  - `raw.jsonl` — every entry, machine-readable.
190
190
  - `main.log` + one `.log` per action — the human-readable timelines.
191
191
  - `events.log` / `asserts.log` / `snapshots.log` / `artifacts.log` — cross-cutting rollups.
192
- - `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flame graph, a flow graph of the tree, per-action details, logs, assertions, and the run's snapshots and images. Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
192
+ - `report.html` — the whole run as one self-contained page: a timeline of every action with its events, a flame graph, a flow graph of the tree, per-action details, logs, assertions, the run's snapshots and images, and each action's source (the `<>` button next to a block). Everything is inlined (data, base64 images, d3), so it opens straight from disk and survives being zipped or attached.
193
193
 
194
194
  `ctx` also gives each action `assert(cond, msg)` (tallies into the run's verdict), `snapshot(name, value)` (captures JSON state to the run folder), and `artifact(name, file, opts?)` (labels a file the action wrote — name, optional description, MIME inferred from the extension — and returns its absolute path) — so a run is an inspectable artifact, not just an exit code. Write the files you label under `ctx.artifactsFolder`, the run's own output folder.
195
195
 
@@ -3,7 +3,7 @@ import type { ActionRef } from "../foundation/ActionRef.ts";
3
3
  import type { LogStore } from "../output/log/LogStore.ts";
4
4
  import type { SnapshotStore } from "../output/snapshot/SnapshotStore.ts";
5
5
  import type { ArtifactStore } from "../output/artifact/ArtifactStore.ts";
6
- import type { Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
6
+ import type { ActionMeta, Activity, Exec, InstanceEvents, NodeStatus } from "./types.ts";
7
7
  /** Everything a node needs to exist, minus its behavior. */
8
8
  export interface NodeInit {
9
9
  readonly store: LogStore;
@@ -45,6 +45,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
45
45
  readonly path: string;
46
46
  readonly parentId: string | null;
47
47
  readonly tags: string[];
48
+ /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
49
+ meta: ActionMeta | undefined;
48
50
  /** The captured args this node ran with (for the log header); set at run start. */
49
51
  args: readonly unknown[];
50
52
  readonly children: ActionRun[];
@@ -18,6 +18,8 @@ export class ActionRun {
18
18
  path;
19
19
  parentId;
20
20
  tags = [];
21
+ /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
22
+ meta;
21
23
  /** The captured args this node ran with (for the log header); set at run start. */
22
24
  args = [];
23
25
  children = [];
@@ -41,6 +41,7 @@ export declare class ExecutionTree {
41
41
  private readonly snapshotStore;
42
42
  private readonly artifactStore;
43
43
  private readonly registry;
44
+ private readonly schemaCache;
44
45
  private readonly unsettled;
45
46
  private readonly originalLog;
46
47
  private streaming;
@@ -101,6 +102,12 @@ export declare class ExecutionTree {
101
102
  private printSummary;
102
103
  private verdictOk;
103
104
  private nodeView;
105
+ /**
106
+ * The node's self-description, projected for the record. The root's comes from the om
107
+ * builder; a child's from its action definition. The args schema is converted here, once per
108
+ * definition, so a step launched a thousand times converts its schema once.
109
+ */
110
+ private metaView;
104
111
  /** Test seam: the projected run view (same object written to result.json). */
105
112
  runViewForTest(): RunView;
106
113
  /** Test seam: the run's curated artifacts. */
@@ -61,6 +61,7 @@ export class ExecutionTree {
61
61
  snapshotStore;
62
62
  artifactStore = new ArtifactStore();
63
63
  registry = [];
64
+ schemaCache = new Map();
64
65
  unsettled = new Set();
65
66
  originalLog = console.log.bind(console);
66
67
  streaming;
@@ -137,6 +138,7 @@ export class ExecutionTree {
137
138
  // into the parent's log, attributed to this child's id.
138
139
  bubble: (message) => this.append(parent, "child", id, message),
139
140
  });
141
+ node.meta = spec.meta;
140
142
  parent.children.push(node);
141
143
  this.registry.push(node);
142
144
  // Launch reference — the parent's pointer to the child's own (absolute) log, plus where
@@ -386,9 +388,34 @@ export class ExecutionTree {
386
388
  endedAt: node.endedAt,
387
389
  duration: node.duration,
388
390
  logFile: this.logFile(node.path),
391
+ ...this.metaView(node),
389
392
  children: node.children.map((child) => this.nodeView(child)),
390
393
  };
391
394
  }
395
+ /**
396
+ * The node's self-description, projected for the record. The root's comes from the om
397
+ * builder; a child's from its action definition. The args schema is converted here, once per
398
+ * definition, so a step launched a thousand times converts its schema once.
399
+ */
400
+ metaView(node) {
401
+ const meta = node.parentId === null
402
+ ? { description: this.description, mcp: this.mcp, describeArgs: node.meta?.describeArgs }
403
+ : node.meta;
404
+ if (!meta)
405
+ return {};
406
+ let inputSchema;
407
+ if (meta.describeArgs) {
408
+ if (!this.schemaCache.has(meta.describeArgs)) {
409
+ this.schemaCache.set(meta.describeArgs, meta.describeArgs().inputSchema);
410
+ }
411
+ inputSchema = this.schemaCache.get(meta.describeArgs);
412
+ }
413
+ return {
414
+ ...(meta.description === undefined ? {} : { description: { ...meta.description } }),
415
+ ...(meta.mcp === undefined ? {} : { mcp: { ...meta.mcp } }),
416
+ ...(inputSchema === undefined ? {} : { inputSchema }),
417
+ };
418
+ }
392
419
  /** Test seam: the projected run view (same object written to result.json). */
393
420
  runViewForTest() {
394
421
  return this.runView();
@@ -49,9 +49,13 @@ class Builder {
49
49
  // No `.args()` on this path, so there is nothing to describe — or to resolve against.
50
50
  const describeArgs = () => describeSchema(undefined);
51
51
  const parseArgs = (supplied) => supplied;
52
+ const meta = {
53
+ ...(description === undefined ? {} : { description }),
54
+ ...(mcp === undefined ? {} : { mcp }),
55
+ };
52
56
  const exec = body;
53
57
  const create = (...args) => {
54
- const spec = { name, args, tags: [], body: exec, definedAt };
58
+ const spec = { name, args, tags: [], body: exec, definedAt, meta };
55
59
  return ExecutionTree.require().launch(spec);
56
60
  };
57
61
  return Object.assign(create, {
@@ -102,6 +106,11 @@ class ArgsBuilder {
102
106
  // that built it — rather than in whichever copy the discovery child is running.
103
107
  const schema = this.schema;
104
108
  const describeArgs = () => describeSchema(schema);
109
+ const meta = {
110
+ ...(description === undefined ? {} : { description }),
111
+ ...(mcp === undefined ? {} : { mcp }),
112
+ describeArgs,
113
+ };
105
114
  // `safeParse` is what applies `.default()`s, so this is where an advertised default
106
115
  // becomes the effective one. A refusal names the action and every failing field.
107
116
  const parseArgs = (supplied) => {
@@ -112,7 +121,7 @@ class ArgsBuilder {
112
121
  };
113
122
  const exec = body;
114
123
  const create = (...args) => {
115
- const spec = { name, args, tags: [], body: exec, definedAt };
124
+ const spec = { name, args, tags: [], body: exec, definedAt, meta };
116
125
  return ExecutionTree.require().launch(spec);
117
126
  };
118
127
  return Object.assign(create, {
package/dist/core/om.js CHANGED
@@ -37,6 +37,9 @@ function launch(name, site, description, exposure, body, schema) {
37
37
  }
38
38
  const tree = new ExecutionTree(name, site, description, exposure);
39
39
  ExecutionTree.current = tree;
40
+ // The root's description and exposure live on the tree; its schema rides on the node.
41
+ if (schema !== undefined)
42
+ tree.root.meta = { describeArgs: () => describeSchema(schema) };
40
43
  const rootBody = (ctx) => body({
41
44
  signal: ctx.signal,
42
45
  logs: ctx.logs,
@@ -239,4 +239,17 @@ export interface LaunchSpec {
239
239
  readonly body: Exec<object, unknown, unknown>;
240
240
  /** `file:line` where the action was defined; carried onto the node for its log header. */
241
241
  readonly definedAt: string | undefined;
242
+ /** What the definition declared about itself — carried onto the node for the run record. */
243
+ readonly meta?: ActionMeta;
244
+ }
245
+ /**
246
+ * A definition's self-description, as it travels onto each node it launches: what
247
+ * `.describe()` and `.mcp()` said, and the `.args()` schema as JSON Schema. Read only by the
248
+ * run record (`result.json`, `report.html`) — the runtime never branches on it.
249
+ */
250
+ export interface ActionMeta {
251
+ readonly description?: OmDescription;
252
+ readonly mcp?: ResolvedMcpExposure;
253
+ /** Converts the declared args schema; called once, at finalize. */
254
+ readonly describeArgs?: () => DescribedArgs;
242
255
  }
@@ -33,10 +33,14 @@ export async function writeReport(file, input) {
33
33
  const assets = [];
34
34
  const files = await collectFiles(input, assets);
35
35
  const snapshots = await collectSnapshots(input);
36
+ const root = await projectRoot(input.folder);
36
37
  const payload = {
37
38
  version: 1,
38
39
  name: input.name,
39
40
  folder: input.folder,
41
+ projectRoot: root,
42
+ sources: definitionSources(input.run.root, root),
43
+ code: await definitionCode(input.run.root),
40
44
  generatedAt: Date.now(),
41
45
  run: input.run,
42
46
  entries: input.entries,
@@ -55,29 +59,29 @@ function renderPage(input, payload, assets, code) {
55
59
  const blobs = assets
56
60
  .map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
57
61
  .join("\n");
58
- return `<!doctype html>
59
- <html lang="en">
60
- <head>
61
- <meta charset="utf-8">
62
- <meta name="viewport" content="width=device-width, initial-scale=1">
63
- <title>${escapeHtml(title)}</title>
64
- <style>${code.css}</style>
65
- </head>
66
- <body>
67
- <div id="loader" role="status" aria-live="polite">
68
- <div class="loader-card">
69
- <div class="loader-spin"></div>
70
- <div class="loader-title">${escapeHtml(title)}</div>
71
- <div class="loader-step" id="loader-step">Loading the run record…</div>
72
- </div>
73
- </div>
74
- <div id="app" hidden></div>
75
- <script id="omkit-data" type="application/json">${inertJson(payload)}</script>
76
- ${blobs}
77
- <script>${inertScript(code.d3)}</script>
78
- <script>${inertScript(code.client)}</script>
79
- </body>
80
- </html>
62
+ return `<!doctype html>
63
+ <html lang="en">
64
+ <head>
65
+ <meta charset="utf-8">
66
+ <meta name="viewport" content="width=device-width, initial-scale=1">
67
+ <title>${escapeHtml(title)}</title>
68
+ <style>${code.css}</style>
69
+ </head>
70
+ <body>
71
+ <div id="loader" role="status" aria-live="polite">
72
+ <div class="loader-card">
73
+ <div class="loader-spin"></div>
74
+ <div class="loader-title">${escapeHtml(title)}</div>
75
+ <div class="loader-step" id="loader-step">Loading the run record…</div>
76
+ </div>
77
+ </div>
78
+ <div id="app" hidden></div>
79
+ <script id="omkit-data" type="application/json">${inertJson(payload)}</script>
80
+ ${blobs}
81
+ <script>${inertScript(code.d3)}</script>
82
+ <script>${inertScript(code.client)}</script>
83
+ </body>
84
+ </html>
81
85
  `;
82
86
  }
83
87
  async function collectFiles(input, assets) {
@@ -117,7 +121,11 @@ async function collectFiles(input, assets) {
117
121
  out.push({ ...base, size, text: await readFile(abs, "utf8") });
118
122
  }
119
123
  else {
120
- out.push({ ...base, size, omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary" });
124
+ out.push({
125
+ ...base,
126
+ size,
127
+ omitted: isText(meta.mime) ? `larger than ${mb(TEXT_CAP)}` : "binary",
128
+ });
121
129
  }
122
130
  };
123
131
  for (const a of input.artifacts) {
@@ -150,7 +158,14 @@ async function collectSnapshots(input) {
150
158
  continue;
151
159
  const name = e.message.slice(0, split);
152
160
  const abs = e.message.slice(split + 3);
153
- const base = { seq: ++seq, name, nodeId: e.nodeId, ts: e.ts, file: abs, rel: relTo(input.folder, abs) };
161
+ const base = {
162
+ seq: ++seq,
163
+ name,
164
+ nodeId: e.nodeId,
165
+ ts: e.ts,
166
+ file: abs,
167
+ rel: relTo(input.folder, abs),
168
+ };
154
169
  try {
155
170
  const raw = await readFile(abs, "utf8");
156
171
  if (raw.length > SNAPSHOT_CAP) {
@@ -183,6 +198,100 @@ function artifactTimes(entries) {
183
198
  }
184
199
  return out;
185
200
  }
201
+ /**
202
+ * The project the run belongs to, for project-relative paths: the nearest ancestor of the run
203
+ * folder holding `.git`, else the folder `logs/` sits in (the root omkit was given), else cwd.
204
+ */
205
+ async function projectRoot(folder) {
206
+ let dir = path.resolve(folder);
207
+ for (;;) {
208
+ try {
209
+ await stat(path.join(dir, ".git"));
210
+ return dir;
211
+ }
212
+ catch {
213
+ /* keep climbing */
214
+ }
215
+ const up = path.dirname(dir);
216
+ if (up === dir)
217
+ break;
218
+ dir = up;
219
+ }
220
+ return process.env.OMKIT_ROOT ? path.resolve(process.env.OMKIT_ROOT) : process.cwd();
221
+ }
222
+ /** Each distinct `definedAt` site → its file, relative to the project root, forward-slashed, no line. */
223
+ function definitionSources(root, projectDir) {
224
+ const out = {};
225
+ const visit = (n) => {
226
+ if (n.definedAt && !(n.definedAt in out)) {
227
+ const file = n.definedAt.replace(/:\d+(:\d+)?$/, "");
228
+ out[n.definedAt] = path.relative(projectDir, file).split(path.sep).join("/") || file;
229
+ }
230
+ n.children.forEach(visit);
231
+ };
232
+ visit(root);
233
+ return out;
234
+ }
235
+ /** Longest excerpt shown per definition; a body past this is cut, and says so. */
236
+ const CODE_LINES = 300;
237
+ /**
238
+ * Each distinct `definedAt` site → the source of the definition there: the whole top-level
239
+ * statement holding that line (`export const x = action(…)…run(…)`, or the `om(…).run(…)` call),
240
+ * found with the TypeScript parser so it is the real extent, not a guess at one. A site whose
241
+ * file is gone or unparseable gets a window of lines around it instead, or nothing.
242
+ */
243
+ async function definitionCode(root) {
244
+ const sites = new Set();
245
+ const visit = (n) => {
246
+ if (n.definedAt)
247
+ sites.add(n.definedAt);
248
+ n.children.forEach(visit);
249
+ };
250
+ visit(root);
251
+ if (sites.size === 0)
252
+ return {};
253
+ // Loaded only here: the parser is heavy, and a run that never writes a report never pays for it.
254
+ const ts = (await import("typescript")).default;
255
+ const out = {};
256
+ const texts = new Map();
257
+ for (const site of sites) {
258
+ const m = /^(.*):(\d+)(?::\d+)?$/.exec(site);
259
+ if (!m)
260
+ continue;
261
+ const file = m[1];
262
+ const line = Number(m[2]);
263
+ if (!texts.has(file))
264
+ texts.set(file, await readFile(file, "utf8").catch(() => null));
265
+ const text = texts.get(file);
266
+ if (!text)
267
+ continue;
268
+ const lang = /\.[cm]?tsx?$/i.test(file) ? "ts" : "js";
269
+ const lines = text.split(/\r?\n/);
270
+ let from = Math.max(1, line - 10);
271
+ let to = Math.min(lines.length, line + 20);
272
+ try {
273
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
274
+ const pos = sf.getPositionOfLineAndCharacter(Math.min(line, sf.getLineStarts().length) - 1, 0);
275
+ const stmt = sf.statements.find((s) => s.getStart(sf) <= pos && pos < s.getEnd()) ??
276
+ sf.statements.find((s) => s.getFullStart() <= pos && pos < s.getEnd());
277
+ if (stmt) {
278
+ from = sf.getLineAndCharacterOfPosition(stmt.getStart(sf, true)).line + 1;
279
+ to = sf.getLineAndCharacterOfPosition(stmt.getEnd()).line + 1;
280
+ }
281
+ }
282
+ catch {
283
+ /* fall back to the window around the line */
284
+ }
285
+ const truncated = to - from + 1 > CODE_LINES;
286
+ out[site] = {
287
+ start: from,
288
+ text: lines.slice(from - 1, truncated ? from - 1 + CODE_LINES : to).join("\n"),
289
+ lang,
290
+ ...(truncated ? { truncated } : {}),
291
+ };
292
+ }
293
+ return out;
294
+ }
186
295
  async function walk(dir, depth = 0) {
187
296
  if (depth > 4)
188
297
  return [];
@@ -153,10 +153,13 @@ button { font: inherit; color: inherit; }
153
153
  .guide::before { content: ""; position: absolute; left: 7px; top: 0; bottom: 0; border-left: 1px solid var(--line); }
154
154
 
155
155
  .chart { position: relative; min-width: 0; }
156
- .chart svg { display: block; }
157
- .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; pointer-events: none; }
156
+ .chart svg { display: block; overflow: hidden; }
157
+ .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; cursor: pointer; }
158
+ .chart .hit { fill: transparent; cursor: pointer; }
159
+ .chart .hit:hover + .bar { filter: brightness(1.15); }
158
160
  .chart .bar-label.outside { fill: var(--text); }
159
- .chart .mark-label { font-size: 11px; fill: var(--text-2); pointer-events: none; }
161
+ .chart .mark-label { font-size: 11px; fill: var(--text-2); cursor: pointer; }
162
+ .chart .mark-label:hover { fill: var(--text); }
160
163
  .chart .grid line { stroke: var(--line-soft); }
161
164
  .chart .cursor { stroke: var(--text-2); stroke-dasharray: 3 3; pointer-events: none; }
162
165
  .chart .link { fill: none; stroke: var(--text-3); stroke-width: 1.2; }
@@ -175,7 +178,7 @@ button { font: inherit; color: inherit; }
175
178
  .flow-link { fill: none; stroke: var(--text-3); stroke-width: 1.3; }
176
179
  .flame-rect { cursor: pointer; stroke: var(--panel); stroke-width: 1; }
177
180
  .flame-rect.selected { stroke: var(--text); stroke-width: 2; }
178
- .flame-text { font-size: 11px; fill: var(--bar-text); pointer-events: none; }
181
+ .flame-text { font-size: 11px; fill: var(--bar-text); cursor: pointer; }
179
182
 
180
183
  /* ── details ────────────────────────────────────────────── */
181
184
  .details { display: grid; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; }
@@ -252,6 +255,31 @@ table.list tr:hover td { background: var(--raise); }
252
255
  }
253
256
  .tip .tt { font-weight: 600; margin-bottom: 2px; }
254
257
  .tip .ts { color: var(--text-2); }
258
+ .tip.wide { max-width: 420px; min-width: 260px; }
259
+ .tip .mono { font-family: var(--mono); font-size: 11px; overflow-wrap: anywhere; }
260
+ .tip .small { color: var(--text-2); }
261
+ .tip .badges { display: flex; flex-wrap: wrap; gap: 5px; margin: 6px 0; }
262
+ .tip .badge { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; background: var(--raise); font-size: 11px; color: var(--text-2); }
263
+ .tip .badge.mcp { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
264
+ .tip .summary { margin: 4px 0 8px; line-height: 1.45; }
265
+ .tip .tkv { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; font-variant-numeric: tabular-nums; }
266
+ .tip .tkv .k, .tip .sec { color: var(--text-3); }
267
+ .tip .sec { margin: 8px 0 3px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
268
+ .tip .sec.fail { color: var(--fail); }
269
+ .params { display: grid; gap: 3px; }
270
+ .param { font: 11px/1.4 var(--mono); }
271
+ .param .pn { color: var(--text); }
272
+ .param .pt { color: var(--accent); margin-left: 6px; }
273
+ .param .pd { color: var(--text-3); margin-left: 6px; }
274
+ .param .pdesc { font-family: var(--sans); color: var(--text-2); margin-left: 10px; }
275
+ .kv .v.defined { display: flex; align-items: center; gap: 6px; }
276
+ .kv .v.defined a { min-width: 0; overflow-wrap: anywhere; font-family: var(--mono); font-size: 12px; }
277
+ .icon-btn { flex: none; border: 1px solid var(--line); background: var(--panel-2); color: var(--text-2); border-radius: 6px; width: 24px; height: 22px; display: grid; place-items: center; cursor: pointer; padding: 0; }
278
+ .icon-btn:hover { color: var(--text); border-color: var(--accent); }
279
+ .icon-btn svg { width: 13px; height: 13px; }
280
+ .tip .hint { margin-top: 8px; color: var(--text-3); font-size: 11px; }
281
+ .tip kbd { font: 600 10px var(--mono); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; color: var(--text-2); }
282
+ .badge-inline { display: inline-block; padding: 0 7px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 11px; font-weight: 600; }
255
283
  .tip img { display: block; max-width: 240px; max-height: 150px; border-radius: 4px; margin: 6px 0; border: 1px solid var(--line); }
256
284
  .modal { position: fixed; inset: 0; background: rgba(5, 8, 18, 0.72); z-index: 60; display: grid; place-items: center; padding: 28px; }
257
285
  .modal .box { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); max-width: min(1200px, 100%); max-height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
@@ -264,6 +292,52 @@ table.list tr:hover td { background: var(--raise); }
264
292
  .menu button:hover { background: var(--raise); }
265
293
  .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--raise); border: 1px solid var(--line); padding: 7px 14px; border-radius: 8px; z-index: 70; }
266
294
 
295
+ /* ── title info, locate, defined-at, code popover ───────── */
296
+ .title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
297
+ .title-row h1 { min-width: 0; }
298
+ .info-btn { flex: none; width: 22px; height: 22px; border: 0; border-radius: 50%; background: none; color: var(--text-3); display: grid; place-items: center; cursor: help; padding: 0; }
299
+ .info-btn:hover, .info-btn:focus-visible { color: var(--accent); background: var(--accent-soft); }
300
+ .info-btn svg { width: 17px; height: 17px; }
301
+ .gal-item .cap .locate { margin-left: auto; width: 22px; height: 20px; }
302
+ .kv .v.defined { flex-wrap: wrap; }
303
+ .kv .v.defined .defined-name { color: var(--text-2); font-family: var(--mono); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
304
+ a.icon-btn { text-decoration: none; }
305
+ .chart .code-btn, .flow-card .code-btn { cursor: pointer; opacity: 0; transition: opacity 0.12s; }
306
+ .chart g.r:hover .code-btn, .chart g.r.sel .code-btn, .flow-card:hover .code-btn, .flow-card.selected .code-btn { opacity: 1; }
307
+ .code-btn circle { fill: var(--panel); stroke: var(--text-3); stroke-width: 1.2; }
308
+ .code-btn text { fill: var(--text-2); font: 700 9px var(--mono); pointer-events: none; }
309
+ .code-btn:hover circle { stroke: var(--accent); }
310
+ .code-btn:hover text { fill: var(--accent); }
311
+ .chart line.pin.flash, .flame-view line.pin.flash { animation: pin-flash 0.6s ease-in-out 3; }
312
+ .chart circle.mark.flash { animation: mark-flash 0.6s ease-in-out 3; transform-box: fill-box; transform-origin: center; }
313
+ @keyframes pin-flash { 50% { stroke-width: 5; } }
314
+ @keyframes mark-flash { 50% { transform: scale(1.9); } }
315
+ .code-pop {
316
+ position: fixed; z-index: 58; width: 640px; max-width: 92vw; max-height: 70vh; display: flex; flex-direction: column;
317
+ background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: var(--shadow); overflow: hidden;
318
+ }
319
+ .code-pop .cp-head { display: flex; align-items: center; gap: 6px; padding: 6px 8px 6px 12px; background: var(--panel-2); border-bottom: 1px solid var(--line); }
320
+ .code-pop .cp-path { font: 12px var(--mono); color: var(--text-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
321
+ .code-pop .cp-name { flex: 1; text-align: right; color: var(--text-3); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
322
+ .code-pop .cp-body { overflow: auto; min-height: 0; }
323
+ .code-pop .cp-code { font: 11.5px/1.65 var(--mono); padding: 8px 0; min-width: max-content; }
324
+ .code-pop .cl { display: grid; grid-template-columns: 48px auto; white-space: pre; }
325
+ .code-pop .cl.at { background: var(--accent-soft); }
326
+ .code-pop .ln { color: var(--text-3); text-align: right; padding-right: 14px; user-select: none; }
327
+ .code-pop .lc { padding-right: 16px; }
328
+ .code-pop .cp-more { padding: 6px 14px 10px; font-size: 12px; }
329
+ :root { --tk-c: #6f7a99; --tk-s: #7ee0a3; --tk-n: #f0b26b; --tk-k: #c792ea; --tk-f: #82aaff; --tk-t: #ffcb6b; }
330
+ :root[data-theme="light"] { --tk-c: #6a737d; --tk-s: #116329; --tk-n: #b35900; --tk-k: #cf222e; --tk-f: #6639ba; --tk-t: #953800; }
331
+ @media (prefers-color-scheme: light) {
332
+ :root:not([data-theme="dark"]) { --tk-c: #6a737d; --tk-s: #116329; --tk-n: #b35900; --tk-k: #cf222e; --tk-f: #6639ba; --tk-t: #953800; }
333
+ }
334
+ .tk-c { color: var(--tk-c); font-style: italic; }
335
+ .tk-s { color: var(--tk-s); }
336
+ .tk-n { color: var(--tk-n); }
337
+ .tk-k { color: var(--tk-k); }
338
+ .tk-f { color: var(--tk-f); }
339
+ .tk-t { color: var(--tk-t); }
340
+
267
341
  @media (max-width: 1100px) {
268
342
  .bottom { grid-template-columns: minmax(0, 1fr); }
269
343
  .legend, .overview { display: none; }
@@ -40,6 +40,10 @@
40
40
  download: "M12 4v11M7 10l5 5 5-5M5 20h14",
41
41
  copy: "M9 9h11v11H9zM5 15V4h11",
42
42
  stop: "M6 6h12v12H6z",
43
+ info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01",
44
+ locate: "M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM12 2v3M12 19v3M2 12h3M19 12h3M12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z",
45
+ code: "M8 7l-5 5 5 5M16 7l5 5-5 5",
46
+ external: "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6",
43
47
  };
44
48
  const svgIcon = (name, extra) =>
45
49
  `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${
@@ -297,6 +301,11 @@
297
301
  zoomToDomain(Math.max(0, d0), Math.max(0, d0) + ms);
298
302
  });
299
303
  app.querySelector("#more").addEventListener("click", (ev) => openMenu(ev.currentTarget));
304
+ const info = app.querySelector("#om-info");
305
+ info.addEventListener("mouseenter", (ev) => showTip(ev, omTip(), true));
306
+ info.addEventListener("mousemove", (ev) => moveTip(ev));
307
+ info.addEventListener("mouseleave", hideTip);
308
+ info.addEventListener("click", () => select(model.root.id));
300
309
 
301
310
  // ── zoom behaviours ──
302
311
  const chartZoom = d3
@@ -393,6 +402,9 @@
393
402
  select(n.id);
394
403
  });
395
404
  r.addEventListener("dblclick", () => focusNode(n));
405
+ r.addEventListener("mouseenter", (ev) => showTip(ev, nodeTip(n), true, 280));
406
+ r.addEventListener("mousemove", (ev) => moveTip(ev));
407
+ r.addEventListener("mouseleave", hideTip);
396
408
  });
397
409
  }
398
410
  function treeRowHtml(n) {
@@ -488,12 +500,18 @@
488
500
  .data(rowsNow, (n) => n.id)
489
501
  .join((enter) => {
490
502
  const r = enter.append("g").attr("class", "r");
503
+ // A transparent target spanning the bar *and* its label, so a 2px bar is still hoverable.
504
+ r.append("rect").attr("class", "hit").attr("y", BAR_Y - 4).attr("height", BAR_H + 8);
491
505
  r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
492
506
  r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
493
507
  r.append("g").attr("class", "marks");
508
+ const cb = r.append("g").attr("class", "code-btn");
509
+ cb.append("circle").attr("r", 9);
510
+ cb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
494
511
  return r;
495
512
  })
496
- .attr("transform", (_n, i) => `translate(0,${i * ROW_H})`);
513
+ .attr("transform", (_n, i) => `translate(0,${i * ROW_H})`)
514
+ .classed("sel", (n) => n.id === state.selected);
497
515
 
498
516
  g.select("rect.bar")
499
517
  .attr("x", (n) => x(n.start))
@@ -504,17 +522,7 @@
504
522
  .attr("stroke-width", (n) => (n.status === "failed" ? 2 : null))
505
523
  .attr("stroke-dasharray", (n) => (n.status === "cancelled" ? "4 3" : null))
506
524
  .classed("selected", (n) => n.id === state.selected)
507
- .on("click", (ev, n) => {
508
- ev.stopPropagation();
509
- select(n.id);
510
- })
511
- .on("dblclick", (ev, n) => {
512
- ev.stopPropagation();
513
- focusNode(n);
514
- })
515
- .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n)))
516
- .on("mousemove", (ev) => moveTip(ev))
517
- .on("mouseleave", hideTip);
525
+ .call(bindNode);
518
526
 
519
527
  g.select("text.bar-label").each(function (n) {
520
528
  const t = d3.select(this);
@@ -524,10 +532,34 @@
524
532
  const need = text.length * 6.6 + 14;
525
533
  const inside = w >= need;
526
534
  const clipX = Math.max(x0, 0);
527
- t.text(inside ? text : w > 60 ? truncate(text, (w - 14) / 6.6) : text)
528
- .classed("outside", !inside && w <= 60)
529
- .attr("x", !inside && w <= 60 ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20));
535
+ const outside = !inside && w <= 60;
536
+ const shown = inside || outside ? text : truncate(text, (w - 14) / 6.6);
537
+ const tw = shown.length * 6.6;
538
+ // an outside label that would run off the chart hangs left of its bar instead
539
+ const flip = outside && x0 + Math.max(w, 3) + 6 + tw > W;
540
+ const lx = flip ? x0 - 6 : outside ? x0 + Math.max(w, 3) + 6 : Math.min(clipX + 8, x0 + w - 20);
541
+ t.text(shown).classed("outside", outside).attr("x", lx).attr("text-anchor", flip ? "end" : "start");
542
+ // the hit target covers bar + label, padded so a sliver of a bar is still easy to reach
543
+ const left = Math.min(x0, flip ? lx - tw : lx) - 6;
544
+ const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
545
+ d3.select(this.parentNode).select("rect.hit").attr("x", left).attr("width", Math.max(16, right - left));
546
+ // the <> sits just past the block (or just before it, when that would leave the chart)
547
+ // …and, for a block spanning the chart, inside it right after its label
548
+ const cx =
549
+ right + 8 <= W - 10 ? right + 8 : left - 8 >= 10 ? left - 8 : Math.min(W - 12, (flip ? x0 : lx + tw) + 14);
550
+ d3.select(this.parentNode)
551
+ .select("g.code-btn")
552
+ .style("display", hasCode(n) ? null : "none")
553
+ .attr("transform", `translate(${cx},${BAR_Y + BAR_H / 2})`);
530
554
  });
555
+ g.select("g.code-btn")
556
+ .on("click", (ev, n) => {
557
+ ev.stopPropagation();
558
+ toggleCode(n, ev);
559
+ })
560
+ .on("mouseenter", (ev) => showTip(ev, "Show source"))
561
+ .on("mouseleave", hideTip);
562
+ g.selectAll("rect.hit, text.bar-label").call(bindNode);
531
563
 
532
564
  g.select("g.marks").each(function (n) {
533
565
  drawMarks(d3.select(this), n, x);
@@ -584,7 +616,14 @@
584
616
  .attr("x", (l) => (flip(l) ? l.d.px + 4 : l.d.px - 4))
585
617
  .attr("text-anchor", (l) => (flip(l) ? "end" : "start"))
586
618
  .attr("y", BAR_Y + BAR_H + 13)
587
- .text((l) => l.text);
619
+ .text((l) => l.text)
620
+ .on("click", (ev, l) => {
621
+ ev.stopPropagation();
622
+ openItem(l.d.m);
623
+ })
624
+ .on("mouseenter", (ev, l) => showTip(ev, markTip(l.d)))
625
+ .on("mousemove", (ev) => moveTip(ev))
626
+ .on("mouseleave", hideTip);
588
627
  }
589
628
  function worst(d) {
590
629
  const all = [d.m, ...d.more].map((m) => m.kind);
@@ -634,12 +673,15 @@
634
673
  .on("mouseleave", () => moveCursor(null));
635
674
  flameSvg.append("g").attr("class", "grid");
636
675
  flameSvg.append("g").attr("class", "cells");
676
+ flameSvg.append("line").attr("class", "pin").attr("stroke", css("--accent")).attr("stroke-width", 1.5).attr("y1", 0).attr("y2", 4000);
637
677
  flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
638
678
  flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
639
679
  redrawX();
640
680
  }
641
681
  function drawFlame(x) {
642
682
  if (!flameSvg) return;
683
+ const px = state.pin != null ? x(state.pin) : -10;
684
+ flameSvg.select("line.pin").attr("x1", px).attr("x2", px);
643
685
  const H = el.flame.clientHeight;
644
686
  const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
645
687
  flameSvg
@@ -669,25 +711,20 @@
669
711
  .attr("fill", (n) => colorOf(n))
670
712
  .attr("fill-opacity", (n) => (n.status === "cancelled" ? 0.55 : 0.92))
671
713
  .classed("selected", (n) => n.id === state.selected)
672
- .on("click", (ev, n) => select(n.id))
673
- .on("dblclick", (ev, n) => {
674
- ev.stopPropagation();
675
- focusNode(n);
676
- })
677
- .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n)))
678
- .on("mousemove", (ev) => moveTip(ev))
679
- .on("mouseleave", hideTip);
714
+ .call(bindNode);
680
715
  cells
681
716
  .select("text")
682
717
  .attr("x", (n) => Math.max(x(n.start), 0) + 5)
683
718
  .text((n) => {
684
719
  const w = x(n.end) - Math.max(x(n.start), 0);
685
720
  return w < 34 ? "" : truncate(`${displayName(n)} ${fmtDur(n.duration)}`, (w - 10) / 6.2);
686
- });
721
+ })
722
+ .call(bindNode);
687
723
  }
688
724
 
689
725
  // ── flow view ──
690
726
  let flowG = null;
727
+ let flowPos = new Map();
691
728
  let flowBounds = null;
692
729
  function renderFlow() {
693
730
  d3.select(el.flow).selectAll("svg").remove();
@@ -747,6 +784,22 @@
747
784
  .attr("cy", 14)
748
785
  .attr("r", 4)
749
786
  .attr("fill", (d) => css(statusVar(d.data.status)));
787
+ const fcb = card
788
+ .filter((d) => hasCode(d.data))
789
+ .append("g")
790
+ .attr("class", "code-btn")
791
+ .attr("transform", `translate(${CW / 2},0)`)
792
+ .on("click", (ev, d) => {
793
+ ev.stopPropagation();
794
+ toggleCode(d.data, ev);
795
+ })
796
+ .on("mouseenter", (ev) => {
797
+ ev.stopPropagation();
798
+ showTip(ev, "Show source");
799
+ });
800
+ fcb.append("circle").attr("r", 9);
801
+ fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
802
+ flowPos = new Map(h.descendants().map((d) => [d.data.id, { x: d.y + CW / 2, y: d.x }]));
750
803
  svg.call(flowZoom);
751
804
  fitFlow(true);
752
805
  }
@@ -815,7 +868,10 @@
815
868
  state.details = true;
816
869
  el.main.classList.remove("no-details");
817
870
  el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
818
- if (chartSvg) chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
871
+ if (chartSvg) {
872
+ chartSvg.selectAll("rect.bar").classed("selected", (n) => n.id === id);
873
+ chartSvg.selectAll("g.r").classed("sel", (n) => n.id === id);
874
+ }
819
875
  if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
820
876
  d3.select(el.flow).selectAll("g.flow-card").classed("selected", (d) => d.data.id === id);
821
877
  renderDetails();
@@ -824,7 +880,7 @@
824
880
  const pad = Math.max(n.end - n.start, 50) * 0.08;
825
881
  zoomToDomain(n.start - pad, n.end + pad);
826
882
  }
827
- function revealRow(n) {
883
+ function revealRow(n, block) {
828
884
  let p = n.parent;
829
885
  let changed = false;
830
886
  while (p) {
@@ -833,7 +889,55 @@
833
889
  }
834
890
  if (changed && state.view === "timeline") renderTimeline();
835
891
  const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
836
- if (row) row.scrollIntoView({ block: "nearest" });
892
+ if (row) row.scrollIntoView({ block: block || "nearest" });
893
+ }
894
+
895
+ /**
896
+ * Bring a gallery item into view in whichever graph is showing: select its node, pin its
897
+ * time, pan (keeping the zoom) so that time is on screen, and pulse its mark.
898
+ */
899
+ function locate(g) {
900
+ if (!g) return;
901
+ if (g.node) select(g.node.id, "snapshots");
902
+ if (state.view === "flow") {
903
+ if (g.node) centerFlowOn(g.node);
904
+ return;
905
+ }
906
+ if (g.t != null) {
907
+ state.pin = g.t;
908
+ const [a, b] = xz().domain();
909
+ if (g.t < a || g.t > b) {
910
+ const k = state.zoom.k;
911
+ const t = d3.zoomIdentity.translate(W / 2 - k * baseX(g.t), 0).scale(k);
912
+ const { z, sel } = activeZoom();
913
+ sel.call(z.transform, t);
914
+ } else redrawX();
915
+ }
916
+ if (g.node && state.view === "timeline") revealRow(g.node, "center");
917
+ flash(g);
918
+ }
919
+ function flash(g) {
920
+ const svgs = [chartSvg, flameSvg].filter(Boolean);
921
+ for (const s of svgs) {
922
+ s.selectAll("line.pin").classed("flash", false).node()?.getBoundingClientRect();
923
+ s.selectAll("line.pin").classed("flash", true);
924
+ }
925
+ if (chartSvg) {
926
+ const hit = chartSvg
927
+ .selectAll("circle.mark")
928
+ .filter((d) => d.m.gallery === g || d.more.some((m) => m.gallery === g));
929
+ hit.classed("flash", false).node()?.getBoundingClientRect();
930
+ hit.classed("flash", true);
931
+ }
932
+ setTimeout(() => d3.selectAll(".flash").classed("flash", false), 1800);
933
+ }
934
+ function centerFlowOn(n) {
935
+ const svg = d3.select(el.flow).select("svg");
936
+ const p = flowPos.get(n.id);
937
+ if (svg.empty() || !p) return;
938
+ const k = Math.max(0.8, d3.zoomTransform(svg.node()).k);
939
+ const t = d3.zoomIdentity.translate(el.flow.clientWidth / 2 - k * p.x, el.flow.clientHeight / 2 - k * p.y).scale(k);
940
+ svg.transition().duration(300).call(flowZoom.transform, t);
837
941
  }
838
942
 
839
943
  function renderDetails() {
@@ -858,14 +962,40 @@
858
962
  <div class="k">Duration</div><div class="v">${fmtDur(n.duration)}</div>
859
963
  <div class="k">Start</div><div class="v">${fmtRel(n.start)} (${fmtClock(n.startAbs)})</div>
860
964
  <div class="k">End</div><div class="v">${fmtRel(n.end)} (${fmtClock(n.endAbs)})</div>
965
+ ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
966
+ ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
861
967
  <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
862
- ${n.v.definedAt ? `<div class="k">Defined at</div><div class="v"><a href="${esc(editorUrl(n.v.definedAt))}" title="${esc(n.v.definedAt)}">${esc(basename(n.v.definedAt))}</a></div>` : ""}
968
+ ${n.v.definedAt ? `<div class="k">Defined at</div><div class="v defined">
969
+ <button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
970
+ ${hasCode(n) ? `<button class="icon-btn" data-show-code aria-label="Show source">${svgIcon("code")}</button>` : ""}
971
+ <a class="icon-btn" href="${esc(editorUrl(n.v.definedAt))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
972
+ <span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
973
+ </div>` : ""}
863
974
  </div>
864
975
  <div class="tabs">${tabs
865
976
  .map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
866
977
  .join("")}</div>
867
978
  <div class="tabbody">${detailTabHtml(n)}</div>
868
979
  </div>`;
980
+ const copySrc = el.details.querySelector("[data-copy-source]");
981
+ if (copySrc) {
982
+ copySrc.addEventListener("click", () => copySource(n));
983
+ copySrc.addEventListener("mouseenter", (ev) =>
984
+ showTip(ev, `<div class="mono">${esc(sourceOf(n))}</div><div class="hint">Click (or press <kbd>C</kbd>) to copy</div>`)
985
+ );
986
+ copySrc.addEventListener("mousemove", (ev) => moveTip(ev));
987
+ copySrc.addEventListener("mouseleave", hideTip);
988
+ }
989
+ const showCode = el.details.querySelector("[data-show-code]");
990
+ if (showCode) {
991
+ showCode.addEventListener("click", (ev) => toggleCode(n, ev));
992
+ showCode.addEventListener("mouseenter", (ev) => showTip(ev, "Show source"));
993
+ showCode.addEventListener("mouseleave", hideTip);
994
+ }
995
+ el.details.querySelectorAll("[data-tip-text]").forEach((a) => {
996
+ a.addEventListener("mouseenter", (ev) => showTip(ev, esc(a.dataset.tipText)));
997
+ a.addEventListener("mouseleave", hideTip);
998
+ });
869
999
  el.details.querySelector(".x").addEventListener("click", () => {
870
1000
  state.details = false;
871
1001
  el.main.classList.add("no-details");
@@ -883,7 +1013,9 @@
883
1013
  function detailTabHtml(n) {
884
1014
  const tab = state.detailTab;
885
1015
  if (tab === "args") {
886
- return n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>';
1016
+ const params = schemaFields(n.v.inputSchema);
1017
+ const schema = params.length ? `<div class="section-title">Schema</div><div class="params">${params.map(paramHtml).join("")}</div><div class="section-title">Values</div>` : "";
1018
+ return schema + (n.v.args && n.v.args.length ? `<pre class="code">${esc(json(n.v.args.length === 1 ? n.v.args[0] : n.v.args))}</pre>` : '<div class="muted">No arguments.</div>');
887
1019
  }
888
1020
  if (tab === "events") {
889
1021
  const evs = n.entries.filter((i) => i.kind !== "info");
@@ -991,6 +1123,14 @@
991
1123
  body.querySelectorAll("tr[data-row]").forEach((tr) =>
992
1124
  tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
993
1125
  );
1126
+ body.querySelectorAll("[data-locate]").forEach((b) => {
1127
+ b.addEventListener("click", (ev) => {
1128
+ ev.stopPropagation();
1129
+ locate(model.gallery[+b.dataset.locate]);
1130
+ });
1131
+ b.addEventListener("mouseenter", (ev) => showTip(ev, `Show in the ${state.view === "flow" ? "flow" : "graph"}`));
1132
+ b.addEventListener("mouseleave", hideTip);
1133
+ });
994
1134
  }
995
1135
  function matches(...parts) {
996
1136
  const q = state.filter.trim().toLowerCase();
@@ -1004,7 +1144,8 @@
1004
1144
  return `<div class="gallery">${g
1005
1145
  .map((x) => {
1006
1146
  const sub = x.type === "snapshot" ? `snapshot · ${x.node ? esc(displayName(x.node)) : ""}` : `${esc(x.f.mime)} · ${fmtSize(x.f.size)}${x.f.omitted ? " · not embedded" : ""}`;
1007
- return `<div class="gal-item">${thumbHtml(x)}<div class="cap"><span class="t">${x.t == null ? "–" : fmtTick(x.t)}</span><span class="n" title="${esc(x.name)}">${esc(x.name)}</span></div><div class="sz">${sub}</div></div>`;
1147
+ const at = x.t != null || x.node ? `<button class="icon-btn locate" data-locate="${model.gallery.indexOf(x)}" aria-label="Show in graph">${svgIcon("locate")}</button>` : "";
1148
+ return `<div class="gal-item">${thumbHtml(x)}<div class="cap"><span class="t">${x.t == null ? "–" : fmtTick(x.t)}</span><span class="n" title="${esc(x.name)}">${esc(x.name)}</span>${at}</div><div class="sz">${sub}</div></div>`;
1008
1149
  })
1009
1150
  .join("")}</div>`;
1010
1151
  }
@@ -1115,11 +1256,54 @@
1115
1256
  }
1116
1257
 
1117
1258
  // ── tooltips ──
1259
+ /** Click selects, double-click focuses, hover explains — the same on every node mark. */
1260
+ function bindNode(sel) {
1261
+ sel
1262
+ .on("click", (ev, n) => {
1263
+ ev.stopPropagation();
1264
+ select(n.id);
1265
+ })
1266
+ .on("dblclick", (ev, n) => {
1267
+ ev.stopPropagation();
1268
+ focusNode(n);
1269
+ })
1270
+ .on("mouseenter", (ev, n) => showTip(ev, nodeTip(n), true))
1271
+ .on("mousemove", (ev) => moveTip(ev))
1272
+ .on("mouseleave", hideTip);
1273
+ }
1118
1274
  function nodeTip(n) {
1119
- return `<div class="tt">${esc(n.name)}</div>
1120
- <div class="ts"><span class="dot ${n.status}"></span> ${esc(n.status)} · ${fmtDur(n.duration)}</div>
1121
- <div class="ts">${fmtRel(n.start)} → ${fmtRel(n.end)}</div>
1122
- ${n.v.tags.length ? `<div class="ts">${esc(n.v.tags.join(" · "))}</div>` : ""}`;
1275
+ hovered = n; // the node `C` copies while its tooltip is up
1276
+ const v = n.v;
1277
+ const shown = n.depth === 0 ? model.data.name : displayName(n);
1278
+ const badges = [
1279
+ `<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
1280
+ v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
1281
+ n.depth === 0 ? '<span class="badge">om</span>' : "",
1282
+ ].join("");
1283
+ const rows = [
1284
+ ["Duration", fmtDur(n.duration)],
1285
+ ["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
1286
+ v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
1287
+ v.definedAt ? ["Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1288
+ n.children.length ? ["Children", String(n.children.length)] : null,
1289
+ n.marks.length ? ["Events", String(n.marks.length)] : null,
1290
+ ].filter(Boolean);
1291
+ const args = v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1292
+ const params = schemaFields(v.inputSchema);
1293
+ const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1294
+ return `<div class="tt">${esc(shown)}</div>
1295
+ ${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
1296
+ <div class="badges">${badges}</div>
1297
+ ${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
1298
+ <div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
1299
+ ${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
1300
+ ${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
1301
+ ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1302
+ ${fails.length ? `<div class="sec fail">Failed</div><div class="mono small">${esc(truncate(String(fails[0].error).split(String.fromCharCode(10))[0], 200))}</div>` : ""}`;
1303
+ }
1304
+ /** The om itself: its node tooltip, plus where the run's record lives. */
1305
+ function omTip() {
1306
+ return `${nodeTip(model.root)}<div class="sec">Run folder</div><div class="mono small">${esc(model.data.folder)}</div>`;
1123
1307
  }
1124
1308
  function markTip(d) {
1125
1309
  const m = d.m;
@@ -1129,12 +1313,91 @@
1129
1313
  const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
1130
1314
  return `<div class="tt">${esc(mk.label)} · ${fmtRel(m.t)}</div>${img}<div>${esc(truncate(m.kind === "snapshot" || m.kind === "artifact" ? m.label : m.e.message, 260))}</div>${extra}${more}`;
1131
1315
  }
1132
- function showTip(ev, html) {
1133
- tip.innerHTML = html;
1134
- tip.hidden = false;
1135
- moveTip(ev);
1316
+ // ── source popover (the <> buttons) — click-toggled, like the tskb explorer's ──
1317
+ const codeEl = document.createElement("div");
1318
+ codeEl.className = "code-pop";
1319
+ codeEl.hidden = true;
1320
+ document.body.appendChild(codeEl);
1321
+ let codeFor = null;
1322
+ let codeAway = null;
1323
+ function hasCode(n) {
1324
+ return !!(n.v.definedAt && model.data.code && model.data.code[n.v.definedAt]);
1325
+ }
1326
+ function toggleCode(n, ev) {
1327
+ if (codeFor === n.id) return closeCode();
1328
+ const site = n.v.definedAt;
1329
+ const c = model.data.code && model.data.code[site];
1330
+ if (!c) return;
1331
+ hideTip();
1332
+ codeFor = n.id;
1333
+ const line = Number((/:(\d+)(?::\d+)?$/.exec(site) || [])[1]);
1334
+ const rel = sourceOf(n);
1335
+ const lines = highlight(c.text).map(
1336
+ (html, i) => `<div class="cl${c.start + i === line ? " at" : ""}"><span class="ln">${c.start + i}</span><span class="lc">${html || " "}</span></div>`
1337
+ );
1338
+ codeEl.innerHTML = `<div class="cp-head">
1339
+ <span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">:${line || c.start}</span></span>
1340
+ <button class="icon-btn" data-a="copy" aria-label="Copy path">${svgIcon("copy")}</button>
1341
+ <a class="icon-btn" href="${esc(editorUrl(site))}" aria-label="Open in VS Code">${svgIcon("external")}</a>
1342
+ <span class="cp-name">${esc(displayName(n))}</span>
1343
+ <button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
1344
+ </div>
1345
+ <div class="cp-body"><div class="cp-code">${lines.join("")}</div>${
1346
+ c.truncated ? '<div class="muted cp-more">Cut short — open the file for the rest.</div>' : ""
1347
+ }</div>`;
1348
+ codeEl.querySelector('[data-a="copy"]').addEventListener("click", () => copy(rel, `Copied ${rel}`));
1349
+ codeEl.querySelector('[data-a="close"]').addEventListener("click", closeCode);
1350
+ codeEl.hidden = false;
1351
+ const x = ev && ev.clientX != null ? ev.clientX : window.innerWidth / 2;
1352
+ const y = ev && ev.clientY != null ? ev.clientY : window.innerHeight / 3;
1353
+ const w = codeEl.offsetWidth;
1354
+ const h = codeEl.offsetHeight;
1355
+ const left = x + 14 + w > window.innerWidth - 8 ? x - w - 14 : x + 14;
1356
+ const top = y + 14 + h > window.innerHeight - 8 ? y - h - 14 : y + 14;
1357
+ codeEl.style.left = `${Math.max(8, left)}px`;
1358
+ codeEl.style.top = `${Math.max(8, top)}px`;
1359
+ const at = codeEl.querySelector(".cl.at");
1360
+ if (at) at.scrollIntoView({ block: "center" });
1361
+ if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1362
+ codeAway = (e) => {
1363
+ if (!codeEl.contains(e.target) && !e.target.closest(".code-btn, [data-show-code]")) closeCode();
1364
+ };
1365
+ setTimeout(() => codeAway && document.addEventListener("mousedown", codeAway, true), 0);
1366
+ }
1367
+ function closeCode() {
1368
+ codeFor = null;
1369
+ codeEl.hidden = true;
1370
+ if (codeAway) document.removeEventListener("mousedown", codeAway, true);
1371
+ codeAway = null;
1372
+ }
1373
+ let tipTimer = 0;
1374
+ let hovered = null;
1375
+ /** The file where `n` is defined, relative to the project root — no line. */
1376
+ function sourceOf(n) {
1377
+ const site = n.v.definedAt;
1378
+ if (!site) return "";
1379
+ return (model.data.sources && model.data.sources[site]) || site.replace(/:\d+(:\d+)?$/, "");
1380
+ }
1381
+ function copySource(n) {
1382
+ const src = n && sourceOf(n);
1383
+ if (src) copy(src, `Copied ${src}`);
1384
+ }
1385
+ let lastEv = null;
1386
+ function showTip(ev, html, wide, delay) {
1387
+ clearTimeout(tipTimer);
1388
+ lastEv = ev;
1389
+ const open = () => {
1390
+ tip.innerHTML = html;
1391
+ tip.classList.toggle("wide", !!wide);
1392
+ tip.hidden = false;
1393
+ moveTip(lastEv);
1394
+ };
1395
+ if (delay && tip.hidden) tipTimer = setTimeout(open, delay);
1396
+ else open();
1136
1397
  }
1137
1398
  function moveTip(ev) {
1399
+ lastEv = ev;
1400
+ if (tip.hidden) return;
1138
1401
  const pad = 14;
1139
1402
  const r = tip.getBoundingClientRect();
1140
1403
  let x = ev.clientX + pad;
@@ -1145,6 +1408,8 @@
1145
1408
  tip.style.top = `${y}px`;
1146
1409
  }
1147
1410
  function hideTip() {
1411
+ hovered = null;
1412
+ clearTimeout(tipTimer);
1148
1413
  tip.hidden = true;
1149
1414
  }
1150
1415
 
@@ -1168,10 +1433,14 @@
1168
1433
  resizeTimer = setTimeout(relayout, 120);
1169
1434
  });
1170
1435
  document.addEventListener("keydown", (e) => {
1436
+ if (e.key === "Escape" && codeFor) return closeCode();
1171
1437
  if (e.target.closest && e.target.closest("input")) return;
1172
1438
  if (e.key === "+" || e.key === "=") zoomBy(1.6);
1173
1439
  else if (e.key === "-") zoomBy(1 / 1.6);
1174
1440
  else if (e.key === "0") fit();
1441
+ else if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && !e.altKey) {
1442
+ copySource(hovered || model.byId.get(state.selected));
1443
+ }
1175
1444
  else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
1176
1445
  const rows = visible();
1177
1446
  const i = rows.findIndex((n) => n.id === state.selected);
@@ -1201,7 +1470,10 @@
1201
1470
  return `
1202
1471
  <header class="top">
1203
1472
  <div class="titles">
1204
- <h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
1473
+ <div class="title-row">
1474
+ <h1 title="${esc(model.data.folder)}">Run: ${esc(model.data.name)}</h1>
1475
+ <button class="info-btn" id="om-info" aria-label="About this om">${svgIcon("info")}</button>
1476
+ </div>
1205
1477
  <div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
1206
1478
  <span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
1207
1479
  <span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
@@ -1281,6 +1553,75 @@
1281
1553
  c.opacity = a;
1282
1554
  return c.formatRgb();
1283
1555
  }
1556
+ const KEYWORDS = new Set(
1557
+ "abstract as async await break case catch class const continue default delete do else enum export extends false finally for from function get if implements import in instanceof interface keyof let new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while yield".split(" ")
1558
+ );
1559
+ const TOKEN =
1560
+ /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(`(?:\\[\s\S]|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')|(\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?n?\b)|([A-Za-z_$][\w$]*)/g;
1561
+ /** Highlighted HTML per source line. Tokens that span lines are closed and reopened at each break. */
1562
+ function highlight(text) {
1563
+ const parts = [];
1564
+ let last = 0;
1565
+ let m;
1566
+ TOKEN.lastIndex = 0;
1567
+ while ((m = TOKEN.exec(text))) {
1568
+ if (m.index > last) parts.push([null, text.slice(last, m.index)]);
1569
+ let cls = null;
1570
+ if (m[1]) cls = "tk-c";
1571
+ else if (m[2]) cls = "tk-s";
1572
+ else if (m[3]) cls = "tk-n";
1573
+ else if (m[4]) {
1574
+ if (KEYWORDS.has(m[4])) cls = "tk-k";
1575
+ else if (/^\s*\(/.test(text.slice(TOKEN.lastIndex, TOKEN.lastIndex + 40))) cls = "tk-f";
1576
+ else if (/^[A-Z]/.test(m[4])) cls = "tk-t";
1577
+ }
1578
+ parts.push([cls, m[0]]);
1579
+ last = TOKEN.lastIndex;
1580
+ }
1581
+ if (last < text.length) parts.push([null, text.slice(last)]);
1582
+ const lines = [""];
1583
+ for (const [cls, chunk] of parts) {
1584
+ chunk.split("\n").forEach((piece, i) => {
1585
+ if (i > 0) lines.push("");
1586
+ if (piece) lines[lines.length - 1] += cls ? `<span class="${cls}">${esc(piece)}</span>` : esc(piece);
1587
+ });
1588
+ }
1589
+ return lines;
1590
+ }
1591
+ function oneLine(v) {
1592
+ try {
1593
+ return typeof v === "string" ? v : JSON.stringify(v);
1594
+ } catch {
1595
+ return String(v);
1596
+ }
1597
+ }
1598
+ /** Top-level fields of a JSON Schema object: name, type, required, default, description. */
1599
+ function schemaFields(schema) {
1600
+ if (!schema || typeof schema !== "object" || !schema.properties) return [];
1601
+ const req = new Set(schema.required || []);
1602
+ return Object.entries(schema.properties).map(([name, p]) => ({
1603
+ name,
1604
+ type: typeOf(p),
1605
+ required: req.has(name) && !(p && "default" in p),
1606
+ def: p && "default" in p ? oneLine(p.default) : undefined,
1607
+ desc: p && p.description,
1608
+ }));
1609
+ }
1610
+ function typeOf(p) {
1611
+ if (!p || typeof p !== "object") return "any";
1612
+ if (p.enum) return p.enum.map((e) => JSON.stringify(e)).join(" | ");
1613
+ if (p.const !== undefined) return JSON.stringify(p.const);
1614
+ if (Array.isArray(p.type)) return p.type.join(" | ");
1615
+ if (p.type === "array") return `${typeOf(p.items)}[]`;
1616
+ if (p.type) return p.type;
1617
+ if (p.anyOf || p.oneOf) return (p.anyOf || p.oneOf).map(typeOf).join(" | ");
1618
+ return "any";
1619
+ }
1620
+ function paramHtml(f) {
1621
+ return `<div class="param"><span class="pn">${esc(f.name)}${f.required ? "" : "?"}</span><span class="pt">${esc(truncate(f.type, 40))}</span>${
1622
+ f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
1623
+ }${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
1624
+ }
1284
1625
  function truncate(s, n) {
1285
1626
  n = Math.max(1, Math.floor(n));
1286
1627
  return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
@@ -1306,19 +1647,19 @@
1306
1647
  if (/build|compile|bundle/.test(s)) return "box";
1307
1648
  return "play";
1308
1649
  }
1309
- function copy(text) {
1310
- const done = () => toast("Copied");
1311
- if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text));
1312
- else fallbackCopy(text);
1650
+ function copy(text, message) {
1651
+ const done = () => toast(message || "Copied");
1652
+ if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, message));
1653
+ else fallbackCopy(text, message);
1313
1654
  }
1314
- function fallbackCopy(text) {
1655
+ function fallbackCopy(text, message) {
1315
1656
  const t = document.createElement("textarea");
1316
1657
  t.value = text;
1317
1658
  document.body.appendChild(t);
1318
1659
  t.select();
1319
1660
  try {
1320
1661
  document.execCommand("copy");
1321
- toast("Copied");
1662
+ toast(message || "Copied");
1322
1663
  } catch {
1323
1664
  toast(text);
1324
1665
  }
@@ -19,6 +19,16 @@ export interface NodeView {
19
19
  readonly duration: number;
20
20
  /** Absolute path to this node's own `.log`. */
21
21
  readonly logFile: string;
22
+ /** What the definition's `.describe()` said; absent when it said nothing. */
23
+ readonly description?: {
24
+ readonly summary: string;
25
+ };
26
+ /** The definition's `.mcp()` exposure; absent when it is not exposed. */
27
+ readonly mcp?: {
28
+ readonly mode: string;
29
+ };
30
+ /** The definition's `.args()` schema as JSON Schema; absent without one. */
31
+ readonly inputSchema?: unknown;
22
32
  readonly children: readonly NodeView[];
23
33
  }
24
34
  /** A curated artifact, as projected into `result.json`. Data-only — no core import. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",