omkit 0.8.11 → 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
@@ -189,7 +189,7 @@ Every action's output, events, asserts, snapshots, and lifecycle land on **one t
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
 
@@ -40,6 +40,7 @@ export async function writeReport(file, input) {
40
40
  folder: input.folder,
41
41
  projectRoot: root,
42
42
  sources: definitionSources(input.run.root, root),
43
+ code: await definitionCode(input.run.root),
43
44
  generatedAt: Date.now(),
44
45
  run: input.run,
45
46
  entries: input.entries,
@@ -58,29 +59,29 @@ function renderPage(input, payload, assets, code) {
58
59
  const blobs = assets
59
60
  .map((a) => `<script type="text/plain" id="${a.id}" data-mime="${a.mime}">${a.data}</script>`)
60
61
  .join("\n");
61
- return `<!doctype html>
62
- <html lang="en">
63
- <head>
64
- <meta charset="utf-8">
65
- <meta name="viewport" content="width=device-width, initial-scale=1">
66
- <title>${escapeHtml(title)}</title>
67
- <style>${code.css}</style>
68
- </head>
69
- <body>
70
- <div id="loader" role="status" aria-live="polite">
71
- <div class="loader-card">
72
- <div class="loader-spin"></div>
73
- <div class="loader-title">${escapeHtml(title)}</div>
74
- <div class="loader-step" id="loader-step">Loading the run record…</div>
75
- </div>
76
- </div>
77
- <div id="app" hidden></div>
78
- <script id="omkit-data" type="application/json">${inertJson(payload)}</script>
79
- ${blobs}
80
- <script>${inertScript(code.d3)}</script>
81
- <script>${inertScript(code.client)}</script>
82
- </body>
83
- </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>
84
85
  `;
85
86
  }
86
87
  async function collectFiles(input, assets) {
@@ -120,7 +121,11 @@ async function collectFiles(input, assets) {
120
121
  out.push({ ...base, size, text: await readFile(abs, "utf8") });
121
122
  }
122
123
  else {
123
- 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
+ });
124
129
  }
125
130
  };
126
131
  for (const a of input.artifacts) {
@@ -153,7 +158,14 @@ async function collectSnapshots(input) {
153
158
  continue;
154
159
  const name = e.message.slice(0, split);
155
160
  const abs = e.message.slice(split + 3);
156
- 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
+ };
157
169
  try {
158
170
  const raw = await readFile(abs, "utf8");
159
171
  if (raw.length > SNAPSHOT_CAP) {
@@ -220,6 +232,66 @@ function definitionSources(root, projectDir) {
220
232
  visit(root);
221
233
  return out;
222
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
+ }
223
295
  async function walk(dir, depth = 0) {
224
296
  if (depth > 4)
225
297
  return [];
@@ -292,6 +292,52 @@ table.list tr:hover td { background: var(--raise); }
292
292
  .menu button:hover { background: var(--raise); }
293
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; }
294
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
+
295
341
  @media (max-width: 1100px) {
296
342
  .bottom { grid-template-columns: minmax(0, 1fr); }
297
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
@@ -496,9 +505,13 @@
496
505
  r.append("rect").attr("class", "bar").attr("rx", 5).attr("y", BAR_Y).attr("height", BAR_H);
497
506
  r.append("text").attr("class", "bar-label").attr("y", BAR_Y + BAR_H / 2 + 4);
498
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("<>");
499
511
  return r;
500
512
  })
501
- .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);
502
515
 
503
516
  g.select("rect.bar")
504
517
  .attr("x", (n) => x(n.start))
@@ -530,7 +543,22 @@
530
543
  const left = Math.min(x0, flip ? lx - tw : lx) - 6;
531
544
  const right = Math.max(x0 + Math.max(w, 3), flip ? x0 : lx + tw) + 6;
532
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})`);
533
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);
534
562
  g.selectAll("rect.hit, text.bar-label").call(bindNode);
535
563
 
536
564
  g.select("g.marks").each(function (n) {
@@ -645,12 +673,15 @@
645
673
  .on("mouseleave", () => moveCursor(null));
646
674
  flameSvg.append("g").attr("class", "grid");
647
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);
648
677
  flameSvg.append("line").attr("class", "cursor").attr("y1", 0).attr("y2", 4000).attr("visibility", "hidden");
649
678
  flameSvg.call(flameZoom).call(flameZoom.transform, state.zoom);
650
679
  redrawX();
651
680
  }
652
681
  function drawFlame(x) {
653
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);
654
685
  const H = el.flame.clientHeight;
655
686
  const ticks = x.ticks(Math.max(2, Math.floor(W / 90)));
656
687
  flameSvg
@@ -693,6 +724,7 @@
693
724
 
694
725
  // ── flow view ──
695
726
  let flowG = null;
727
+ let flowPos = new Map();
696
728
  let flowBounds = null;
697
729
  function renderFlow() {
698
730
  d3.select(el.flow).selectAll("svg").remove();
@@ -752,6 +784,22 @@
752
784
  .attr("cy", 14)
753
785
  .attr("r", 4)
754
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 }]));
755
803
  svg.call(flowZoom);
756
804
  fitFlow(true);
757
805
  }
@@ -820,7 +868,10 @@
820
868
  state.details = true;
821
869
  el.main.classList.remove("no-details");
822
870
  el.tree.querySelectorAll(".row").forEach((r) => r.classList.toggle("selected", r.dataset.id === id));
823
- 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
+ }
824
875
  if (flameSvg) flameSvg.selectAll("rect.flame-rect").classed("selected", (n) => n.id === id);
825
876
  d3.select(el.flow).selectAll("g.flow-card").classed("selected", (d) => d.data.id === id);
826
877
  renderDetails();
@@ -829,7 +880,7 @@
829
880
  const pad = Math.max(n.end - n.start, 50) * 0.08;
830
881
  zoomToDomain(n.start - pad, n.end + pad);
831
882
  }
832
- function revealRow(n) {
883
+ function revealRow(n, block) {
833
884
  let p = n.parent;
834
885
  let changed = false;
835
886
  while (p) {
@@ -838,7 +889,55 @@
838
889
  }
839
890
  if (changed && state.view === "timeline") renderTimeline();
840
891
  const row = el.tree.querySelector(`.row[data-id="${CSS.escape(n.id)}"]`);
841
- 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);
842
941
  }
843
942
 
844
943
  function renderDetails() {
@@ -866,7 +965,12 @@
866
965
  ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
867
966
  ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
868
967
  <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
869
- ${n.v.definedAt ? `<div class="k">Defined at</div><div class="v defined"><a href="${esc(editorUrl(n.v.definedAt))}" title="Open ${esc(n.v.definedAt)} in VS Code">${esc(sourceOf(n))}</a><button class="icon-btn" data-copy-source title="Copy relative path (C)">${svgIcon("copy")}</button></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>` : ""}
870
974
  </div>
871
975
  <div class="tabs">${tabs
872
976
  .map(([k, l]) => `<button class="tab ${state.detailTab === k ? "active" : ""}" data-tab="${k}">${l}</button>`)
@@ -874,7 +978,24 @@
874
978
  <div class="tabbody">${detailTabHtml(n)}</div>
875
979
  </div>`;
876
980
  const copySrc = el.details.querySelector("[data-copy-source]");
877
- if (copySrc) copySrc.addEventListener("click", () => copySource(n));
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
+ });
878
999
  el.details.querySelector(".x").addEventListener("click", () => {
879
1000
  state.details = false;
880
1001
  el.main.classList.add("no-details");
@@ -1002,6 +1123,14 @@
1002
1123
  body.querySelectorAll("tr[data-row]").forEach((tr) =>
1003
1124
  tr.addEventListener("click", () => openItem(model.items[+tr.dataset.row]))
1004
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
+ });
1005
1134
  }
1006
1135
  function matches(...parts) {
1007
1136
  const q = state.filter.trim().toLowerCase();
@@ -1015,7 +1144,8 @@
1015
1144
  return `<div class="gallery">${g
1016
1145
  .map((x) => {
1017
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" : ""}`;
1018
- 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>`;
1019
1149
  })
1020
1150
  .join("")}</div>`;
1021
1151
  }
@@ -1144,7 +1274,7 @@
1144
1274
  function nodeTip(n) {
1145
1275
  hovered = n; // the node `C` copies while its tooltip is up
1146
1276
  const v = n.v;
1147
- const shown = displayName(n);
1277
+ const shown = n.depth === 0 ? model.data.name : displayName(n);
1148
1278
  const badges = [
1149
1279
  `<span class="badge"><span class="dot ${n.status}"></span>${esc(n.status)}</span>`,
1150
1280
  v.mcp ? `<span class="badge mcp" title="Exposed over MCP">MCP · ${esc(v.mcp.mode)}</span>` : "",
@@ -1171,6 +1301,10 @@
1171
1301
  ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1172
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>` : ""}`;
1173
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>`;
1307
+ }
1174
1308
  function markTip(d) {
1175
1309
  const m = d.m;
1176
1310
  const mk = MARK[m.kind] || MARK.event;
@@ -1179,6 +1313,63 @@
1179
1313
  const extra = m.gallery && m.gallery.image ? `<div class="ts">${esc(m.gallery.f.rel)} · ${fmtSize(m.gallery.f.size)}</div>` : "";
1180
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}`;
1181
1315
  }
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
+ }
1182
1373
  let tipTimer = 0;
1183
1374
  let hovered = null;
1184
1375
  /** The file where `n` is defined, relative to the project root — no line. */
@@ -1242,6 +1433,7 @@
1242
1433
  resizeTimer = setTimeout(relayout, 120);
1243
1434
  });
1244
1435
  document.addEventListener("keydown", (e) => {
1436
+ if (e.key === "Escape" && codeFor) return closeCode();
1245
1437
  if (e.target.closest && e.target.closest("input")) return;
1246
1438
  if (e.key === "+" || e.key === "=") zoomBy(1.6);
1247
1439
  else if (e.key === "-") zoomBy(1 / 1.6);
@@ -1278,7 +1470,10 @@
1278
1470
  return `
1279
1471
  <header class="top">
1280
1472
  <div class="titles">
1281
- <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>
1282
1477
  <div class="meta"><span class="dot ${ok ? "ok" : "failed"}"></span><span>${ok ? "passed" : "failed"}</span>
1283
1478
  <span class="sep">•</span><span>${fmtDate(run.startedAt)}</span>
1284
1479
  <span class="sep">•</span><span title="body ${fmtDur(run.duration)} · record ${fmtDur(model.span)}">${fmtDur(run.duration)}</span>
@@ -1358,6 +1553,41 @@
1358
1553
  c.opacity = a;
1359
1554
  return c.formatRgb();
1360
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
+ }
1361
1591
  function oneLine(v) {
1362
1592
  try {
1363
1593
  return typeof v === "string" ? v : JSON.stringify(v);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.8.11",
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",