omkit 0.8.12 → 0.8.13

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,7 +185,7 @@ 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. Each node also carries what its definition declared about itself: the `.describe()` summary, the `.mcp()` exposure, and the `.args()` schema as JSON Schema.
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. Each node also records `calledAt`, the line that launched it, so a built-in step (a `prompt`, a `command`) points back at your code rather than omkit's, and a `step(...)` is defined at the line where you wrote it.
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.
@@ -47,6 +47,8 @@ export declare class ActionRun<Result = unknown, Events extends object = object,
47
47
  readonly tags: string[];
48
48
  /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
49
49
  meta: ActionMeta | undefined;
50
+ /** `file:line` of the call that launched this node (absent for the root). */
51
+ calledAt: string | undefined;
50
52
  /** The captured args this node ran with (for the log header); set at run start. */
51
53
  args: readonly unknown[];
52
54
  readonly children: ActionRun[];
@@ -20,6 +20,8 @@ export class ActionRun {
20
20
  tags = [];
21
21
  /** What the definition said about itself (`.describe()`, `.mcp()`, `.args()`); for the record. */
22
22
  meta;
23
+ /** `file:line` of the call that launched this node (absent for the root). */
24
+ calledAt;
23
25
  /** The captured args this node ran with (for the log header); set at run start. */
24
26
  args = [];
25
27
  children = [];
@@ -139,6 +139,7 @@ export class ExecutionTree {
139
139
  bubble: (message) => this.append(parent, "child", id, message),
140
140
  });
141
141
  node.meta = spec.meta;
142
+ node.calledAt = spec.calledAt;
142
143
  parent.children.push(node);
143
144
  this.registry.push(node);
144
145
  // Launch reference — the parent's pointer to the child's own (absolute) log, plus where
@@ -378,6 +379,7 @@ export class ExecutionTree {
378
379
  uuid: node.uuid,
379
380
  name: node.name,
380
381
  definedAt: node.definedAt,
382
+ ...(node.calledAt === undefined ? {} : { calledAt: node.calledAt }),
381
383
  path: node.path,
382
384
  parentId: node.parentId,
383
385
  tags: [...node.tags],
@@ -55,7 +55,9 @@ class Builder {
55
55
  };
56
56
  const exec = body;
57
57
  const create = (...args) => {
58
- const spec = { name, args, tags: [], body: exec, definedAt, meta };
58
+ // where this launch was written — for a battery, the user's call rather than its definition
59
+ const calledAt = callerSite();
60
+ const spec = { name, args, tags: [], body: exec, definedAt, calledAt, meta };
59
61
  return ExecutionTree.require().launch(spec);
60
62
  };
61
63
  return Object.assign(create, {
@@ -121,7 +123,9 @@ class ArgsBuilder {
121
123
  };
122
124
  const exec = body;
123
125
  const create = (...args) => {
124
- const spec = { name, args, tags: [], body: exec, definedAt, meta };
126
+ // where this launch was written — for a battery, the user's call rather than its definition
127
+ const calledAt = callerSite();
128
+ const spec = { name, args, tags: [], body: exec, definedAt, calledAt, meta };
125
129
  return ExecutionTree.require().launch(spec);
126
130
  };
127
131
  return Object.assign(create, {
package/dist/core/step.js CHANGED
@@ -1,4 +1,5 @@
1
- import { action } from "./action.js";
1
+ import { callerSite } from "../foundation/callsite.js";
2
+ import { ExecutionTree } from "./ExecutionTree.js";
2
3
  /**
3
4
  * An inline anonymous action: creates a child node under the ambient current
4
5
  * action and runs `fn` as that node — own id/path/log, tags, error boundary — with
@@ -7,6 +8,17 @@ import { action } from "./action.js";
7
8
  * units use `action(...)`.
8
9
  */
9
10
  export function step(name, fn) {
11
+ // An inline action is defined where it is written — the `step(...)` call — so that is its
12
+ // site, not this file (which is what routing through `action(name).run(fn)` would record).
13
+ const site = callerSite();
14
+ const spec = {
15
+ name,
16
+ args: [],
17
+ tags: [],
18
+ body: fn,
19
+ definedAt: site,
20
+ calledAt: site,
21
+ };
10
22
  // `.result` already resolves the value and rejects on failure/cancel — exactly step's contract.
11
- return action(name).run(fn)().result;
23
+ return ExecutionTree.require().launch(spec).result;
12
24
  }
@@ -241,6 +241,8 @@ export interface LaunchSpec {
241
241
  readonly definedAt: string | undefined;
242
242
  /** What the definition declared about itself — carried onto the node for the run record. */
243
243
  readonly meta?: ActionMeta;
244
+ /** `file:line` of the call that launched this instance; carried onto the node for the record. */
245
+ readonly calledAt?: string;
244
246
  }
245
247
  /**
246
248
  * A definition's self-description, as it travels onto each node it launches: what
@@ -41,6 +41,7 @@ export async function writeReport(file, input) {
41
41
  projectRoot: root,
42
42
  sources: definitionSources(input.run.root, root),
43
43
  code: await definitionCode(input.run.root),
44
+ library: librarySites(input.run.root),
44
45
  generatedAt: Date.now(),
45
46
  run: input.run,
46
47
  entries: input.entries,
@@ -223,15 +224,81 @@ async function projectRoot(folder) {
223
224
  function definitionSources(root, projectDir) {
224
225
  const out = {};
225
226
  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;
227
+ for (const site of [n.definedAt, n.calledAt]) {
228
+ if (!site || site in out)
229
+ continue;
230
+ const file = site.replace(/:\d+(:\d+)?$/, "");
231
+ out[site] = path.relative(projectDir, file).split(path.sep).join("/") || file;
229
232
  }
230
233
  n.children.forEach(visit);
231
234
  };
232
235
  visit(root);
233
236
  return out;
234
237
  }
238
+ /**
239
+ * The deepest statement containing `pos`. For an action's `.run(…)` line that is the whole
240
+ * `export const x = action(…)…` statement (nothing nests the chain); for a call site inside a
241
+ * body — `await step("x", async () => …)`, `await command(…)` — it is that one statement, the
242
+ * callback included, rather than the om that happens to contain it.
243
+ */
244
+ function innermostStatement(ts, sf, pos) {
245
+ let found;
246
+ const visit = (node) => {
247
+ if (pos < node.getStart(sf) || pos >= node.getEnd())
248
+ return;
249
+ const parent = node.parent;
250
+ if (parent?.statements?.includes(node))
251
+ found = node;
252
+ ts.forEachChild(node, visit);
253
+ };
254
+ ts.forEachChild(sf, visit);
255
+ return found;
256
+ }
257
+ /**
258
+ * The 1-based line range to show for a site: its innermost statement, or — when the file will
259
+ * not parse or nothing encloses the line — a window around it.
260
+ */
261
+ function excerptRange(ts, file, text, line, total) {
262
+ try {
263
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
264
+ const lineStart = sf.getPositionOfLineAndCharacter(Math.min(line, sf.getLineStarts().length) - 1, 0);
265
+ // the line's first token, so indentation doesn't fall outside the statement it opens
266
+ const pos = lineStart + (/^\s*/.exec(text.slice(lineStart))?.[0].length ?? 0);
267
+ const stmt = innermostStatement(ts, sf, pos);
268
+ if (stmt) {
269
+ return {
270
+ from: sf.getLineAndCharacterOfPosition(stmt.getStart(sf, true)).line + 1,
271
+ to: sf.getLineAndCharacterOfPosition(stmt.getEnd()).line + 1,
272
+ };
273
+ }
274
+ }
275
+ catch {
276
+ /* fall back to the window around the line */
277
+ }
278
+ return { from: Math.max(1, line - 10), to: Math.min(total, line + 20) };
279
+ }
280
+ /** omkit's own package folder — `src/output/writers` (or `dist/…`) is three levels in. */
281
+ const OMKIT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
282
+ /**
283
+ * Sites inside omkit itself or an installed package — a battery's definition or a step's
284
+ * wrapper — where the viewer shows the user's call instead of library source.
285
+ */
286
+ function librarySites(root) {
287
+ const out = new Set();
288
+ const inside = (file) => {
289
+ const rel = path.relative(OMKIT_DIR, file);
290
+ return (!rel.startsWith("..") && !path.isAbsolute(rel)) || /[\\/]node_modules[\\/]/.test(file);
291
+ };
292
+ const visit = (n) => {
293
+ for (const site of [n.definedAt, n.calledAt]) {
294
+ if (site && inside(site.replace(/:\d+(:\d+)?$/, "")))
295
+ out.add(site);
296
+ }
297
+ n.children.forEach(visit);
298
+ };
299
+ visit(root);
300
+ return [...out];
301
+ }
235
302
  /** Longest excerpt shown per definition; a body past this is cut, and says so. */
236
303
  const CODE_LINES = 300;
237
304
  /**
@@ -245,6 +312,8 @@ async function definitionCode(root) {
245
312
  const visit = (n) => {
246
313
  if (n.definedAt)
247
314
  sites.add(n.definedAt);
315
+ if (n.calledAt)
316
+ sites.add(n.calledAt);
248
317
  n.children.forEach(visit);
249
318
  };
250
319
  visit(root);
@@ -267,21 +336,7 @@ async function definitionCode(root) {
267
336
  continue;
268
337
  const lang = /\.[cm]?tsx?$/i.test(file) ? "ts" : "js";
269
338
  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
- }
339
+ const { from, to } = excerptRange(ts, file, text, line, lines.length);
285
340
  const truncated = to - from + 1 > CODE_LINES;
286
341
  out[site] = {
287
342
  start: from,
@@ -338,6 +338,23 @@ a.icon-btn { text-decoration: none; }
338
338
  .tk-f { color: var(--tk-f); }
339
339
  .tk-t { color: var(--tk-t); }
340
340
 
341
+ /* ── popover tabs, prompt panel, flow snapshot strip ───── */
342
+ .code-pop .cp-tabs { display: flex; gap: 2px; padding: 0 8px; border-bottom: 1px solid var(--line); background: var(--panel-2); }
343
+ .code-pop .cp-tabs .tab { padding: 6px 9px; font-size: 12px; }
344
+ .pp { padding: 12px 14px 14px; display: grid; gap: 10px; }
345
+ .pp-q { font-size: 14px; font-weight: 600; white-space: pre-wrap; }
346
+ .pp-grid { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 4px 10px; font-size: 12px; }
347
+ .pp-k { color: var(--text-3); font-size: 12px; }
348
+ .pp-choices { margin: 0; padding-left: 18px; }
349
+ .pp-choices li.picked { color: var(--accent); font-weight: 600; }
350
+ .pp-answer { display: grid; gap: 6px; justify-items: start; }
351
+ .pp-answer pre { width: 100%; }
352
+ .tip .tprompt { font-weight: 600; white-space: pre-wrap; margin-bottom: 4px; }
353
+ .flow-shots .shot { cursor: pointer; }
354
+ .flow-shots .shot rect { fill: var(--panel-2); stroke: var(--line); }
355
+ .flow-shots .shot:hover rect { stroke: var(--accent); }
356
+ .flow-shots .shot text { fill: var(--text-3); font: 600 9px var(--mono); pointer-events: none; }
357
+
341
358
  @media (max-width: 1100px) {
342
359
  .bottom { grid-template-columns: minmax(0, 1fr); }
343
360
  .legend, .overview { display: none; }
@@ -733,7 +733,10 @@
733
733
  const h = d3.hierarchy(model.root, (n) => n.children);
734
734
  const CW = 210;
735
735
  const CH = 46;
736
- d3.tree().nodeSize([CH + 18, CW + 70])(h);
736
+ // cards that took snapshots carry a strip of them underneath, so leave room for it
737
+ const shotsOf = (n) => model.gallery.filter((g) => g.node === n);
738
+ const SH = model.gallery.some((g) => g.node) ? 36 : 0;
739
+ d3.tree().nodeSize([CH + 18 + SH, CW + 70])(h);
737
740
  let minX = Infinity;
738
741
  let maxX = -Infinity;
739
742
  let maxY = 0;
@@ -799,6 +802,61 @@
799
802
  });
800
803
  fcb.append("circle").attr("r", 9);
801
804
  fcb.append("text").attr("text-anchor", "middle").attr("y", 3.5).text("<>");
805
+ const TW = 46;
806
+ const TH = 28;
807
+ const strip = card
808
+ .filter((d) => shotsOf(d.data).length > 0)
809
+ .append("g")
810
+ .attr("class", "flow-shots")
811
+ .attr("transform", `translate(6,${CH + 4})`);
812
+ strip.each(function (d) {
813
+ const shots = shotsOf(d.data);
814
+ const fit = Math.floor((CW - 12) / (TW + 4));
815
+ const shown = shots.length > fit ? shots.slice(0, fit - 1) : shots;
816
+ const g = d3.select(this);
817
+ const cell = g
818
+ .selectAll("g.shot")
819
+ .data(shown)
820
+ .join("g")
821
+ .attr("class", "shot")
822
+ .attr("transform", (_x, i) => `translate(${i * (TW + 4)},0)`)
823
+ .on("click", (ev, x) => {
824
+ ev.stopPropagation();
825
+ openGallery(x);
826
+ })
827
+ .on("mouseenter", (ev, x) => {
828
+ ev.stopPropagation();
829
+ showTip(ev, galleryTip(x));
830
+ })
831
+ .on("mousemove", (ev) => moveTip(ev))
832
+ .on("mouseleave", hideTip);
833
+ cell.append("rect").attr("width", TW).attr("height", TH).attr("rx", 4);
834
+ cell
835
+ .filter((x) => x.image)
836
+ .append("image")
837
+ .attr("href", (x) => model.asset(x.f.asset))
838
+ .attr("x", 1)
839
+ .attr("y", 1)
840
+ .attr("width", TW - 2)
841
+ .attr("height", TH - 2)
842
+ .attr("preserveAspectRatio", "xMidYMid slice");
843
+ cell
844
+ .filter((x) => !x.image)
845
+ .append("text")
846
+ .attr("x", TW / 2)
847
+ .attr("y", TH / 2 + 3.5)
848
+ .attr("text-anchor", "middle")
849
+ .text((x) => (x.type === "snapshot" ? "{ }" : "file"));
850
+ if (shots.length > shown.length) {
851
+ const more = g.append("g").attr("class", "shot more").attr("transform", `translate(${shown.length * (TW + 4)},0)`)
852
+ .on("click", (ev) => {
853
+ ev.stopPropagation();
854
+ select(d.data.id, "snapshots");
855
+ });
856
+ more.append("rect").attr("width", TW).attr("height", TH).attr("rx", 4);
857
+ more.append("text").attr("x", TW / 2).attr("y", TH / 2 + 3.5).attr("text-anchor", "middle").text(`+${shots.length - shown.length}`);
858
+ }
859
+ });
802
860
  flowPos = new Map(h.descendants().map((d) => [d.data.id, { x: d.y + CW / 2, y: d.x }]));
803
861
  svg.call(flowZoom);
804
862
  fitFlow(true);
@@ -965,10 +1023,10 @@
965
1023
  ${n.v.description ? `<div class="k">Summary</div><div class="v">${esc(n.v.description.summary)}</div>` : ""}
966
1024
  ${n.v.mcp ? `<div class="k">MCP</div><div class="v"><span class="badge-inline">${esc(n.v.mcp.mode)}</span></div>` : ""}
967
1025
  <div class="k">Path</div><div class="v">${esc(n.v.path)}</div>
968
- ${n.v.definedAt ? `<div class="k">Defined at</div><div class="v defined">
1026
+ ${userSite(n) ? `<div class="k">${siteLabel(n)}</div><div class="v defined">
969
1027
  <button class="icon-btn" data-copy-source aria-label="Copy relative path">${svgIcon("copy")}</button>
970
1028
  ${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>
1029
+ <a class="icon-btn" href="${esc(editorUrl(userSite(n)))}" aria-label="Open in VS Code" data-tip-text="Open in VS Code">${svgIcon("external")}</a>
972
1030
  <span class="defined-name" >${esc(basename(sourceOf(n)))}</span>
973
1031
  </div>` : ""}
974
1032
  </div>
@@ -1284,23 +1342,37 @@
1284
1342
  ["Duration", fmtDur(n.duration)],
1285
1343
  ["When", `${fmtRel(n.start)} → ${fmtRel(n.end)}`],
1286
1344
  v.tags.length ? ["Tags", esc(v.tags.join(" · "))] : null,
1287
- v.definedAt ? ["Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1345
+ userSite(n) ? [siteLabel(n) === "Called at" ? "Called" : "Defined", `<span class="mono">${esc(sourceOf(n))}</span>`] : null,
1288
1346
  n.children.length ? ["Children", String(n.children.length)] : null,
1289
1347
  n.marks.length ? ["Events", String(n.marks.length)] : null,
1290
1348
  ].filter(Boolean);
1291
- const args = v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1349
+ const args = !promptOf(n) && v.args && v.args.length ? truncate(oneLine(v.args.length === 1 ? v.args[0] : v.args), 220) : "";
1292
1350
  const params = schemaFields(v.inputSchema);
1351
+ const pr = promptOf(n);
1352
+ const promptRows = pr
1353
+ ? `<div class="sec">Prompt</div><div class="tprompt">${esc(truncate(pr.message || "", 200))}</div>${
1354
+ pr.answer ? `<div class="tkv"><span class="k">Answer</span><span class="mono">${esc(truncate(typeof pr.answer.value === "string" ? pr.answer.value : oneLine(pr.answer.value), 120))}</span>${pr.answer.via ? `<span class="k">Via</span><span>${esc(pr.answer.via)}</span>` : ""}</div>` : ""
1355
+ }`
1356
+ : "";
1293
1357
  const fails = model.run.failures.filter((f) => f.action === n.id || f.action === n.name || f.action === v.path);
1294
1358
  return `<div class="tt">${esc(shown)}</div>
1295
1359
  ${shown !== n.name ? `<div class="ts mono">${esc(truncate(n.name, 140))}</div>` : ""}
1296
1360
  <div class="badges">${badges}</div>
1297
1361
  ${v.description ? `<div class="summary">${esc(v.description.summary)}</div>` : '<div class="summary muted">No description.</div>'}
1298
1362
  <div class="tkv">${rows.map(([k, val]) => `<span class="k">${k}</span><span>${val}</span>`).join("")}</div>
1363
+ ${promptRows}
1299
1364
  ${params.length ? `<div class="sec">Args schema</div><div class="params">${params.map(paramHtml).join("")}</div>` : ""}
1300
1365
  ${args ? `<div class="sec">Args</div><div class="mono small">${esc(args)}</div>` : ""}
1301
1366
  ${v.definedAt ? '<div class="hint">Press <kbd>C</kbd> to copy the path</div>' : ""}
1302
1367
  ${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
1368
  }
1369
+ function galleryTip(x) {
1370
+ const img = x.image ? `<img src="${model.asset(x.f.asset)}" alt="">` : "";
1371
+ const body = x.type === "snapshot"
1372
+ ? `<div class="mono small">${esc(truncate(x.s.value !== undefined ? oneLine(x.s.value) : x.s.text || "", 200))}</div>`
1373
+ : `<div class="ts">${esc(x.f.mime)} · ${fmtSize(x.f.size)}</div>`;
1374
+ return `<div class="tt">${esc(x.name)}${x.t != null ? ` · ${fmtRel(x.t)}` : ""}</div>${img}${body}`;
1375
+ }
1304
1376
  /** The om itself: its node tooltip, plus where the run's record lives. */
1305
1377
  function omTip() {
1306
1378
  return `${nodeTip(model.root)}<div class="sec">Run folder</div><div class="mono small">${esc(model.data.folder)}</div>`;
@@ -1320,42 +1392,113 @@
1320
1392
  document.body.appendChild(codeEl);
1321
1393
  let codeFor = null;
1322
1394
  let codeAway = null;
1395
+ /** A site inside omkit or an installed package — library source, not the user's. */
1396
+ function isLib(site) {
1397
+ return !!(site && model.data.library && model.data.library.includes(site));
1398
+ }
1399
+ /**
1400
+ * The site worth showing for `n`: where it is defined, unless that is library source (a
1401
+ * battery, a prompt), in which case the user's call that launched it.
1402
+ */
1403
+ function userSite(n) {
1404
+ const { definedAt, calledAt } = n.v;
1405
+ if (definedAt && !isLib(definedAt)) return definedAt;
1406
+ if (calledAt && !isLib(calledAt)) return calledAt;
1407
+ return definedAt || calledAt;
1408
+ }
1409
+ function siteLabel(n) {
1410
+ return userSite(n) === n.v.definedAt ? "Defined at" : "Called at";
1411
+ }
1412
+ function codeOf(site) {
1413
+ return site && model.data.code ? model.data.code[site] : undefined;
1414
+ }
1415
+ /** The popover's tabs for `n`, most useful first. */
1416
+ function codeTabs(n) {
1417
+ const tabs = [];
1418
+ if (promptOf(n)) tabs.push({ key: "prompt", label: "Prompt" });
1419
+ const mine = userSite(n);
1420
+ if (codeOf(mine)) tabs.push({ key: "user", label: siteLabel(n) === "Called at" ? "Call site" : "Source", site: mine });
1421
+ const def = n.v.definedAt;
1422
+ if (def && def !== mine && codeOf(def)) tabs.push({ key: "def", label: isLib(def) ? "Definition (omkit)" : "Definition", site: def });
1423
+ return tabs;
1424
+ }
1323
1425
  function hasCode(n) {
1324
- return !!(n.v.definedAt && model.data.code && model.data.code[n.v.definedAt]);
1426
+ return codeTabs(n).length > 0;
1325
1427
  }
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;
1428
+ function relOf(site) {
1429
+ return (model.data.sources && model.data.sources[site]) || String(site).replace(/:\d+(:\d+)?$/, "");
1430
+ }
1431
+ function codeHtml(site) {
1432
+ const c = codeOf(site);
1333
1433
  const line = Number((/:(\d+)(?::\d+)?$/.exec(site) || [])[1]);
1334
- const rel = sourceOf(n);
1335
1434
  const lines = highlight(c.text).map(
1336
1435
  (html, i) => `<div class="cl${c.start + i === line ? " at" : ""}"><span class="ln">${c.start + i}</span><span class="lc">${html || " "}</span></div>`
1337
1436
  );
1437
+ return `<div class="cp-code">${lines.join("")}</div>${c.truncated ? '<div class="muted cp-more">Cut short — open the file for the rest.</div>' : ""}`;
1438
+ }
1439
+ function promptHtml(p) {
1440
+ const choices = p.choices && p.choices.length
1441
+ ? `<div class="pp-k">Choices</div><div class="pp-v"><ol class="pp-choices">${p.choices
1442
+ .map((c) => {
1443
+ const value = typeof c === "string" ? c : c.value;
1444
+ const label = typeof c === "string" ? c : c.label;
1445
+ const picked = p.answer && p.answer.value === value;
1446
+ return `<li class="${picked ? "picked" : ""}">${esc(label)}${label !== value ? ` <span class="muted">(${esc(value)})</span>` : ""}</li>`;
1447
+ })
1448
+ .join("")}</ol></div>`
1449
+ : "";
1450
+ const answer = p.answer
1451
+ ? `<div class="pp-answer"><span class="pp-k">Answer</span><pre class="code">${esc(typeof p.answer.value === "string" ? p.answer.value : json(p.answer.value))}</pre>${
1452
+ p.answer.via ? `<span class="badge-inline">via ${esc(p.answer.via)}</span>` : ""
1453
+ }</div>`
1454
+ : '<div class="muted">No answer recorded.</div>';
1455
+ return `<div class="pp">
1456
+ <div class="pp-q">${esc(p.message || "(no message)")}</div>
1457
+ ${p.hint ? `<div class="mono small">${esc(p.hint)}</div>` : ""}
1458
+ <div class="pp-grid">
1459
+ <div class="pp-k">Kind</div><div class="pp-v">${esc(p.kind)}</div>
1460
+ ${p.def !== undefined ? `<div class="pp-k">Default</div><div class="pp-v mono">${esc(oneLine(p.def))}</div>` : ""}
1461
+ ${p.timeoutMs !== undefined ? `<div class="pp-k">Timeout</div><div class="pp-v">${p.timeoutMs > 0 && isFinite(p.timeoutMs) ? fmtDur(p.timeoutMs) : "none"}</div>` : ""}
1462
+ ${choices}
1463
+ </div>
1464
+ ${answer}
1465
+ </div>`;
1466
+ }
1467
+ function toggleCode(n, ev, want) {
1468
+ if (codeFor === n.id && !want) return closeCode();
1469
+ const tabs = codeTabs(n);
1470
+ if (!tabs.length) return;
1471
+ const tab = tabs.find((t) => t.key === want) || tabs[0];
1472
+ hideTip();
1473
+ const reopen = codeFor !== n.id;
1474
+ codeFor = n.id;
1475
+ const site = tab.site || userSite(n);
1476
+ const rel = site ? relOf(site) : "";
1477
+ const line = site ? (/:(\d+)(?::\d+)?$/.exec(site) || [])[1] : "";
1338
1478
  codeEl.innerHTML = `<div class="cp-head">
1339
- <span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">:${line || c.start}</span></span>
1479
+ ${site ? `<span class="cp-path" title="${esc(site)}">${esc(rel)}<span class="muted">${line ? ":" + line : ""}</span></span>
1340
1480
  <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>
1481
+ <a class="icon-btn" href="${esc(editorUrl(site))}" aria-label="Open in VS Code">${svgIcon("external")}</a>` : ""}
1342
1482
  <span class="cp-name">${esc(displayName(n))}</span>
1343
1483
  <button class="icon-btn" data-a="close" aria-label="Close">${svgIcon("close")}</button>
1344
1484
  </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}`));
1485
+ ${tabs.length > 1 ? `<div class="cp-tabs">${tabs.map((t) => `<button class="tab ${t === tab ? "active" : ""}" data-tab="${t.key}">${esc(t.label)}</button>`).join("")}</div>` : ""}
1486
+ <div class="cp-body">${tab.key === "prompt" ? promptHtml(promptOf(n)) : codeHtml(site)}</div>`;
1487
+ const cp = codeEl.querySelector('[data-a="copy"]');
1488
+ if (cp) cp.addEventListener("click", () => copy(rel, `Copied ${rel}`));
1349
1489
  codeEl.querySelector('[data-a="close"]').addEventListener("click", closeCode);
1490
+ codeEl.querySelectorAll(".cp-tabs .tab").forEach((b) => b.addEventListener("click", () => toggleCode(n, null, b.dataset.tab)));
1350
1491
  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`;
1492
+ if (reopen) {
1493
+ const x = ev && ev.clientX != null ? ev.clientX : window.innerWidth / 2;
1494
+ const y = ev && ev.clientY != null ? ev.clientY : window.innerHeight / 3;
1495
+ const w = codeEl.offsetWidth;
1496
+ const h = Math.min(codeEl.offsetHeight, window.innerHeight * 0.7);
1497
+ const left = x + 14 + w > window.innerWidth - 8 ? x - w - 14 : x + 14;
1498
+ const top = y + 14 + h > window.innerHeight - 8 ? y - h - 14 : y + 14;
1499
+ codeEl.style.left = `${Math.max(8, left)}px`;
1500
+ codeEl.style.top = `${Math.max(8, top)}px`;
1501
+ }
1359
1502
  const at = codeEl.querySelector(".cl.at");
1360
1503
  if (at) at.scrollIntoView({ block: "center" });
1361
1504
  if (codeAway) document.removeEventListener("mousedown", codeAway, true);
@@ -1372,11 +1515,10 @@
1372
1515
  }
1373
1516
  let tipTimer = 0;
1374
1517
  let hovered = null;
1375
- /** The file where `n` is defined, relative to the project root — no line. */
1518
+ /** The file worth opening for `n` (see {@link userSite}), relative to the project root — no line. */
1376
1519
  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+)?$/, "");
1520
+ const site = userSite(n);
1521
+ return site ? relOf(site) : "";
1380
1522
  }
1381
1523
  function copySource(n) {
1382
1524
  const src = n && sourceOf(n);
@@ -1622,12 +1764,48 @@
1622
1764
  f.def !== undefined ? `<span class="pd">= ${esc(truncate(f.def, 24))}</span>` : ""
1623
1765
  }${f.desc ? `<div class="pdesc">${esc(f.desc)}</div>` : ""}</div>`;
1624
1766
  }
1767
+ /**
1768
+ * What a `prompt` battery node asked and got back: its options (the node's one argument) plus
1769
+ * the `prompt` / `answer` events it emitted. `null` for anything that is not a prompt.
1770
+ */
1771
+ function promptOf(n) {
1772
+ if (n._prompt !== undefined) return n._prompt;
1773
+ n._prompt = null;
1774
+ if (n.name !== "prompt" || n.depth === 0) return null;
1775
+ const opts = (n.v.args && n.v.args[0] && typeof n.v.args[0] === "object" ? n.v.args[0] : {}) || {};
1776
+ let message = opts.message;
1777
+ let answer = null;
1778
+ for (const it of n.entries) {
1779
+ if (it.e.level !== "event") continue;
1780
+ if (it.e.message.startsWith("prompt · ")) message = it.e.message.slice("prompt · ".length);
1781
+ else if (it.e.message.startsWith("answer · ")) {
1782
+ try {
1783
+ answer = JSON.parse(it.e.message.slice("answer · ".length));
1784
+ } catch {
1785
+ answer = { value: it.e.message.slice("answer · ".length) };
1786
+ }
1787
+ }
1788
+ }
1789
+ if (message === undefined && !answer) return null;
1790
+ n._prompt = {
1791
+ message,
1792
+ kind: opts.kind || "input",
1793
+ hint: opts.hint,
1794
+ choices: opts.choices,
1795
+ def: opts.default,
1796
+ timeoutMs: opts.timeoutMs,
1797
+ answer,
1798
+ };
1799
+ return n._prompt;
1800
+ }
1625
1801
  function truncate(s, n) {
1626
1802
  n = Math.max(1, Math.floor(n));
1627
1803
  return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s;
1628
1804
  }
1629
1805
  function displayName(n) {
1630
1806
  if (n.depth === 0) return "main";
1807
+ const p = promptOf(n);
1808
+ if (p && p.message) return `prompt: ${truncate(p.message, 48)}`;
1631
1809
  const tag = n.v.tags && n.v.tags[0];
1632
1810
  // Battery steps are named after their kind (`command`, `chromePage`); their first tag
1633
1811
  // is what the om called them, which is the more useful label.
@@ -9,6 +9,8 @@ export interface NodeView {
9
9
  readonly name: string;
10
10
  /** `file:line` where the action was defined (the `.run(...)` site); absent for the root. */
11
11
  readonly definedAt?: string;
12
+ /** `file:line` of the call that launched this node; absent for the root. */
13
+ readonly calledAt?: string;
12
14
  readonly path: string;
13
15
  readonly parentId: string | null;
14
16
  readonly tags: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omkit",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
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",