@webmcp-auto-ui/ui 2.5.37 → 2.5.38

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.
@@ -8,9 +8,9 @@
8
8
  import {
9
9
  createState, injectStyles, mountRunControls, mountHistoryPanel,
10
10
  setupDnD, deleteCellWithConfirm, restoreCellFromSnapshot, addCell,
11
- addImportedCells, registerExecutor, collectDataServers,
11
+ addImportedCells, registerExecutor, collectDataServers, collectWebmcpServers,
12
12
  autosize, openShareModal, registerHistoryObserver,
13
- renderCellLogs,
13
+ renderCellLogs, uid, defaultCellContent,
14
14
  createPublishControls, autoConnectFrontmatterServers,
15
15
  createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
16
16
  lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
@@ -25,6 +25,42 @@ import { extractCellsFromRecipe, extractCellFromMarkdown } from './resource-extr
25
25
  import { mountLeftPane } from './left-pane.js';
26
26
  import { highlightCode } from '../../primitives/markdown-renderer.js';
27
27
  import { createSqlExecutor } from './executors/sql.js';
28
+ import { runCode } from '@webmcp-auto-ui/sdk';
29
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
30
+ import { mountWidget } from '@webmcp-auto-ui/core';
31
+
32
+ // Side-effect imports — register native widget custom elements so that
33
+ // `mountWidget(host, name, params, servers)` can resolve `<auto-${name}>`
34
+ // even when WidgetRenderer.svelte is not mounted on the page (e.g. notebook-
35
+ // viewer, which mounts the notebook widget via vanilla renderNotebook but
36
+ // never imports WidgetRenderer). Without these imports `customElements.get`
37
+ // returns undefined and every `await widget(...)` result falls through to the
38
+ // silent `container.textContent = '[${type}]'` branch.
39
+ import '../simple/stat.svelte';
40
+ import '../simple/kv.svelte';
41
+ import '../simple/list.svelte';
42
+ import '../simple/chart.svelte';
43
+ import '../simple/alert.svelte';
44
+ import '../simple/code.svelte';
45
+ import '../simple/text.svelte';
46
+ import '../simple/actions.svelte';
47
+ import '../simple/tags.svelte';
48
+ import '../rich/stat-card.svelte';
49
+ import '../rich/profile.svelte';
50
+ import '../rich/json-viewer.svelte';
51
+ import '../rich/chart-rich.svelte';
52
+ import '../rich/sankey.svelte';
53
+ import '../rich/hemicycle.svelte';
54
+ import '../rich/data-table.svelte';
55
+ import '../rich/timeline.svelte';
56
+ import '../rich/trombinoscope.svelte';
57
+ import '../rich/cards.svelte';
58
+ import '../rich/grid-data.svelte';
59
+ import '../rich/js-sandbox.svelte';
60
+ import '../rich/log.svelte';
61
+ import '../rich/gallery.svelte';
62
+ import '../rich/carousel.svelte';
63
+ import '../rich/map.svelte';
28
64
 
29
65
  export async function render(container: HTMLElement, data: Record<string, unknown>): Promise<() => void> {
30
66
  injectStyles();
@@ -38,7 +74,13 @@ export async function render(container: HTMLElement, data: Record<string, unknow
38
74
  autoRun: (data as any).autoRun === true,
39
75
  publishedSlug: (data as any).publishedSlug,
40
76
  publishedToken: (data as any).publishedToken,
41
- });
77
+ webmcpServers: (data as any).webmcpServers,
78
+ chatApiBase: (data as any).chatApiBase,
79
+ hidePublishBadge: (data as any).hidePublishBadge === true,
80
+ } as any);
81
+
82
+ // Default src visibility per mode (toggle remains usable for per-cell override).
83
+ for (const c of state.cells) c.hideSource = state.mode === 'view';
42
84
 
43
85
  // Live mode runtime overlay (created lazily). Never mutates state.
44
86
  let overlay: RuntimeOverlay | null = null;
@@ -57,8 +99,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
57
99
  <div class="nbe-shell">
58
100
  <div class="nbe-kicker">
59
101
  <div class="nb-mode-switch" style="margin-left:auto;">
60
- <button class="nb-mode-edit nb-on">edit</button>
61
- <button class="nb-mode-view">view</button>
102
+ <button class="nb-mode-edit ${state.mode === 'edit' ? 'nb-on' : ''}">edit</button>
103
+ <button class="nb-mode-view ${state.mode === 'view' ? 'nb-on' : ''}">view</button>
62
104
  </div>
63
105
  <button class="nb-btn nbe-history-btn">⟲ history</button>
64
106
  <span class="nbe-publish-badge-slot"></span>
@@ -71,11 +113,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
71
113
  <div class="nb-history-panel nbe-history-panel"></div>
72
114
  <div class="nbe-cells"></div>
73
115
  <div class="nbe-footer">
74
- <button class="nb-btn nb-add-cell" data-add="md">+ prose</button>
75
- <button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
76
- <button class="nb-btn nb-add-cell" data-add="js">+ chart</button>
77
- <button class="nb-btn nb-add-cell" data-add-modal="md">+ md</button>
78
- <button class="nb-btn nb-add-cell" data-add-modal="recipe">+ recipe</button>
79
116
  <span class="nbe-share-btn" title="Share">share</span>
80
117
  <span class="nbe-publish-slot"></span>
81
118
  </div>
@@ -233,7 +270,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
233
270
  });
234
271
  const publishCleanup = createPublishControls(state, {
235
272
  buttonSlot: shell.querySelector('.nbe-publish-slot') as HTMLElement,
236
- badgeSlot: shell.querySelector('.nbe-publish-badge-slot') as HTMLElement,
273
+ badgeSlot: state.hidePublishBadge ? undefined : (shell.querySelector('.nbe-publish-badge-slot') as HTMLElement),
237
274
  footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
238
275
  onPublished: () => rerender(),
239
276
  });
@@ -245,6 +282,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
245
282
  const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
246
283
  editBtn.addEventListener('click', () => {
247
284
  state.mode = 'edit';
285
+ for (const c of state.cells) c.hideSource = false;
248
286
  container.classList.remove('nb-view-mode');
249
287
  editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
250
288
  // Leaving view: stop live refresh and clear overlay so frozen snapshots show.
@@ -253,14 +291,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
253
291
  });
254
292
  viewBtn.addEventListener('click', () => {
255
293
  state.mode = 'view';
294
+ for (const c of state.cells) c.hideSource = true;
256
295
  container.classList.add('nb-view-mode');
257
296
  viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
258
297
  if (state.autoRun === true) bootstrapLive();
259
298
  rerender();
260
299
  });
261
300
 
262
- // Left pane (collapsed by default)
263
- const pane = mountLeftPane(leftPaneHost, state, collectDataServers(data), {
301
+
302
+ // Left pane (collapsed by default). Lists remote data servers AND bundled
303
+ // WebMCP servers (autoui, d3, …) in two distinct groups.
304
+ const collectAll = () => [
305
+ ...collectDataServers(data),
306
+ ...collectWebmcpServers(state.webmcpServers),
307
+ ];
308
+ const pane = mountLeftPane(leftPaneHost, state, collectAll(), {
264
309
  onInjectCells: (cells) => {
265
310
  addImportedCells(state, cells, activeCellIdx());
266
311
  rerender();
@@ -269,14 +314,14 @@ export async function render(container: HTMLElement, data: Record<string, unknow
269
314
 
270
315
  // Auto-connect data servers declared in the recipe frontmatter (data.servers).
271
316
  // The notebook reads MCP state passively from canvas.dataServers.
272
- autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
317
+ autoConnectFrontmatterServers(data, () => pane.setServers(collectAll()));
273
318
 
274
319
  // Keep pane servers in sync with canvas changes
275
320
  let canvasUnsub: (() => void) | null = null;
276
321
  try {
277
322
  const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
278
323
  if (canvasAny?.subscribe) {
279
- canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectDataServers(data)));
324
+ canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectAll()));
280
325
  }
281
326
  } catch { /* ignore */ }
282
327
 
@@ -304,38 +349,50 @@ export async function render(container: HTMLElement, data: Record<string, unknow
304
349
  // Executors (same pattern as compact/workspace/document agents)
305
350
  // ---------------------------------------------------------------------------
306
351
 
352
+ /**
353
+ * Convert RunLog[] (objects with {t, msg}) returned by runCode into the
354
+ * string[] format expected by CellResult.logs. Each entry becomes a
355
+ * human-readable "<offset>ms <message>" line. Without this conversion every
356
+ * log entry would coerce to "[object Object]" in renderCellLogs.
357
+ */
358
+ function logsToStrings(logs: Array<{ t: number; msg: string }> | undefined): string[] {
359
+ if (!logs || logs.length === 0) return [];
360
+ return logs.map((l) => `${l.t}ms ${l.msg}`);
361
+ }
362
+
307
363
  async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
308
364
  const start = Date.now();
309
365
  const { cell, scope } = ctx;
310
- try {
311
- const keys = Object.keys(scope);
312
- const values = keys.map((k) => scope[k]);
313
- const src = cell.content.trim();
314
- const body = /^\s*(return|var|let|const|function|class|if|for|while|\/\/|\/\*)/.test(src)
315
- ? src
316
- : `return (async () => { return (${src}); })();`;
317
- // eslint-disable-next-line no-new-func
318
- const fn = new Function(...keys, body);
319
- let result = fn(...values);
320
- if (result && typeof result.then === 'function') result = await result;
321
- const durationMs = Date.now() - start;
322
-
323
- if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs };
324
- if (Array.isArray(result)) {
325
- const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
326
- const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
327
- return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs };
328
- }
329
- if (result && typeof result === 'object') {
330
- const r: any = result;
331
- if (r.data || r.marks || r.mark || r.$schema) {
332
- return { ok: true, kind: 'chart', spec: result, durationMs };
333
- }
366
+ // Accept both `call(...)` (SDK) and `callTool(...)` (legacy / AI-generated)
367
+ // as aliases. runCode injects `call` and `widget` helpers around an
368
+ // AsyncFunction body, so top-level `await` and MCP calls work uniformly.
369
+ const code = cell.content.replace(/\bcallTool\s*\(/g, 'call(');
370
+ const res = await runCode(code, 'js', canvas.multiClient, scope);
371
+ const durationMs = Date.now() - start;
372
+ const logs = logsToStrings(res.logs as any);
373
+ if (res.status === 'error') {
374
+ return { ok: false, error: res.error ?? 'error', errorKind: 'runtime', durationMs, logs };
375
+ }
376
+ // Widgets emitted via `widget(name, params)` or via `*_widget_display(...)`
377
+ // calls surface them as a dedicated kind so the host can mount them.
378
+ const widgets = res.widgets ?? (res.widget ? [res.widget] : []);
379
+ if (widgets.length > 0) {
380
+ return { ok: true, kind: 'widget', widgets, durationMs, logs };
381
+ }
382
+ const result = res.output;
383
+ if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs, logs };
384
+ if (Array.isArray(result)) {
385
+ const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
386
+ const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
387
+ return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs, logs };
388
+ }
389
+ if (result && typeof result === 'object') {
390
+ const r: any = result;
391
+ if (r.data || r.marks || r.mark || r.$schema) {
392
+ return { ok: true, kind: 'chart', spec: result, durationMs, logs };
334
393
  }
335
- return { ok: true, kind: 'value', value: result, durationMs };
336
- } catch (err: any) {
337
- return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
338
394
  }
395
+ return { ok: true, kind: 'value', value: result, durationMs, logs };
339
396
  }
340
397
 
341
398
  // ---------------------------------------------------------------------------
@@ -434,7 +491,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
434
491
  if (!cell.hideResult) {
435
492
  const res = document.createElement('div');
436
493
  res.className = 'nbe-result';
437
- renderResultInto(res, cell, overlay);
494
+ renderResultInto(res, cell, overlay, state);
438
495
  codeCell.appendChild(res);
439
496
  }
440
497
 
@@ -443,9 +500,324 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
443
500
  (head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
444
501
  (head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
445
502
 
503
+ if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
504
+
446
505
  return wrap;
447
506
  }
448
507
 
508
+ /**
509
+ * Per-cell action bar: [+ text] [+ sql] [+ JS] [+ widget] [+ agent]
510
+ * Inserts a new cell directly after this one, or opens a picker / agent input.
511
+ * `widget` and `agent` are wired in dedicated handlers below.
512
+ */
513
+ function renderCellActionBar(state: NotebookState, cell: NotebookCell, rerender: () => void): HTMLElement {
514
+ const bar = document.createElement('div');
515
+ bar.className = 'nbe-cell-actionbar';
516
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
517
+ const insertAfter = (type: 'md' | 'sql' | 'js', content?: string) => {
518
+ const newCell: NotebookCell = {
519
+ id: uid(),
520
+ type,
521
+ content: content ?? defaultCellContent(type),
522
+ hideSource: false, hideResult: false, status: 'stale',
523
+ };
524
+ addImportedCells(state, [newCell], idx);
525
+ rerender();
526
+ };
527
+ bar.innerHTML = `
528
+ <button class="nb-btn nb-cellbar-btn" data-add="md">+ text</button>
529
+ <button class="nb-btn nb-cellbar-btn" data-add="sql">+ sql</button>
530
+ <button class="nb-btn nb-cellbar-btn" data-add="js">+ JS</button>
531
+ <button class="nb-btn nb-cellbar-btn" data-cellbar="widget">+ widget</button>
532
+ <button class="nb-btn nb-cellbar-btn" data-cellbar="agent">+ agent</button>`;
533
+ bar.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
534
+ btn.addEventListener('click', () => insertAfter(btn.dataset.add as 'md' | 'sql' | 'js'));
535
+ });
536
+ bar.querySelector<HTMLElement>('[data-cellbar="widget"]')?.addEventListener('click', () => {
537
+ openWidgetPicker(state, cell, rerender);
538
+ });
539
+ bar.querySelector<HTMLElement>('[data-cellbar="agent"]')?.addEventListener('click', () => {
540
+ toggleAgentBar(bar, state, cell, rerender);
541
+ });
542
+ return bar;
543
+ }
544
+
545
+ /**
546
+ * +widget picker — lists the recipes exposed by every active WebMCP server
547
+ * (state.webmcpServers, populated from frontmatter.webmcp_servers). On pick,
548
+ * inserts a JS cell after the current one with a template that reads the
549
+ * upstream cell's varname (default 'rows') and renders the widget.
550
+ */
551
+ function openWidgetPicker(state: NotebookState, cell: NotebookCell, rerender: () => void): void {
552
+ const servers = state.webmcpServers ?? [];
553
+ type Item = { server: string; name: string; description?: string };
554
+ const items: Item[] = [];
555
+ for (const s of servers) {
556
+ const recipes = s.layer().recipes ?? [];
557
+ for (const r of recipes) items.push({ server: s.name, name: r.name, description: r.description });
558
+ }
559
+ if (items.length === 0) {
560
+ toastNoServers();
561
+ return;
562
+ }
563
+ const upstreamVar = findUpstreamVarname(state, cell);
564
+ const overlay = document.createElement('div');
565
+ overlay.className = 'nbe-picker-overlay';
566
+ overlay.innerHTML = `
567
+ <div class="nbe-picker">
568
+ <header>
569
+ <input class="nbe-picker-q" placeholder="filter widgets…" autofocus />
570
+ <button class="nbe-picker-close" type="button">✕</button>
571
+ </header>
572
+ <ul class="nbe-picker-list"></ul>
573
+ </div>`;
574
+ document.body.appendChild(overlay);
575
+ const close = () => { try { overlay.remove(); } catch { /* ignore */ } };
576
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
577
+ (overlay.querySelector('.nbe-picker-close') as HTMLElement).addEventListener('click', close);
578
+ const list = overlay.querySelector('.nbe-picker-list') as HTMLElement;
579
+ const q = overlay.querySelector('.nbe-picker-q') as HTMLInputElement;
580
+ const render = (filter = '') => {
581
+ const f = filter.toLowerCase().trim();
582
+ list.innerHTML = items
583
+ .filter((i) => !f || i.name.toLowerCase().includes(f) || (i.description ?? '').toLowerCase().includes(f) || i.server.toLowerCase().includes(f))
584
+ .map((i) => `<li data-name="${escapeAttr(i.name)}" data-server="${escapeAttr(i.server)}">
585
+ <span class="nbe-picker-name">${escapeHtml(i.name)}</span>
586
+ <span class="nbe-picker-server">${escapeHtml(i.server)}</span>
587
+ <span class="nbe-picker-desc">${escapeHtml(i.description ?? '')}</span>
588
+ </li>`).join('');
589
+ };
590
+ render();
591
+ q.addEventListener('input', () => render(q.value));
592
+ list.addEventListener('click', (e) => {
593
+ const li = (e.target as HTMLElement).closest('li[data-name]') as HTMLElement | null;
594
+ if (!li) return;
595
+ const name = li.dataset.name as string;
596
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
597
+ const template = upstreamVar
598
+ ? `return widget('${name}', { rows: ${upstreamVar} })`
599
+ : `return widget('${name}', {})`;
600
+ const newCell: NotebookCell = {
601
+ id: uid(), type: 'js',
602
+ content: template,
603
+ hideSource: false, hideResult: false, status: 'stale',
604
+ };
605
+ addImportedCells(state, [newCell], idx);
606
+ close();
607
+ rerender();
608
+ });
609
+ function toastNoServers() {
610
+ const t = document.createElement('div');
611
+ t.className = 'nbe-toast nbe-toast-error nbe-toast-in';
612
+ t.textContent = 'No WebMCP server is enabled — add some in flex first (autoui, d3, observable-plot…)';
613
+ document.body.appendChild(t);
614
+ setTimeout(() => t.remove(), 3500);
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Walk back from `cell` to find the nearest cell with a varname (typically a
620
+ * SQL cell). The widget template will bind to that variable so the chosen
621
+ * widget visualises the upstream cell's output. Returns null if none.
622
+ */
623
+ function findUpstreamVarname(state: NotebookState, cell: NotebookCell): string | null {
624
+ const idx = state.cells.findIndex((c) => c.id === cell.id);
625
+ for (let i = idx; i >= 0; i--) {
626
+ const c = state.cells[i];
627
+ if (c?.varname) return c.varname;
628
+ }
629
+ return null;
630
+ }
631
+
632
+ /**
633
+ * +agent bar — toggled inline below the cell action bar. The user types a
634
+ * prompt in <auto-chat-input>, runAgentLoop is invoked with a focused tool
635
+ * layer that lets the LLM:
636
+ * - read the current cell + scope
637
+ * - rewrite the current cell content
638
+ * - insert a new cell after it (text / sql / js / widget)
639
+ * Every mutation logs into state.history so the user can ⟲ revert.
640
+ * Provider: RemoteLLMProvider via /api/chat (proxy reads env LLM_API_KEY).
641
+ */
642
+ function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void): void {
643
+ const existing = host.parentElement?.querySelector(':scope > .nbe-agent-bar');
644
+ if (existing) { existing.remove(); return; }
645
+ const bar = document.createElement('div');
646
+ bar.className = 'nbe-agent-bar';
647
+ bar.innerHTML = `
648
+ <auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
649
+ <div class="nbe-agent-status" hidden></div>`;
650
+ host.insertAdjacentElement('afterend', bar);
651
+ const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
652
+ const status = bar.querySelector('.nbe-agent-status') as HTMLElement;
653
+ let aborter: AbortController | null = null;
654
+ input.addEventListener('widget:interact', (e: Event) => {
655
+ const detail = (e as CustomEvent).detail ?? {};
656
+ const action = (detail as { action?: string }).action;
657
+ // Prevent the event bubbling to the host (flex/etc.) — otherwise their
658
+ // own widget-interaction handler would launch a SECOND, unconstrained
659
+ // agent loop that renders widgets outside the notebook (canvas blocks).
660
+ if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
661
+ if (action !== 'submit') return;
662
+ e.stopPropagation();
663
+ const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
664
+ if (!text) return;
665
+ void runAgentForCell(text, state, cell, rerender, status, input, () => aborter ??= new AbortController());
666
+ });
667
+ }
668
+
669
+ async function runAgentForCell(
670
+ prompt: string,
671
+ state: NotebookState,
672
+ cell: NotebookCell,
673
+ rerender: () => void,
674
+ status: HTMLElement,
675
+ input: HTMLElement & { disabled?: boolean },
676
+ getAborter: () => AbortController,
677
+ ): Promise<void> {
678
+ status.hidden = false;
679
+ status.textContent = '…';
680
+ input.disabled = true;
681
+ const aborter = getAborter();
682
+ try {
683
+ // Lazy import to keep notebook bundle slim when agent is unused.
684
+ const { RemoteLLMProvider, runAgentLoop } = await import('@webmcp-auto-ui/agent');
685
+ // Host injects chatApiBase via data (flex serves under /flex, notebook-viewer
686
+ // at root). Default '/api/chat' kept for hosts mounting at the root.
687
+ const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
688
+ const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
689
+ const layer = buildAgentLayerForCell(state, cell, rerender);
690
+ const systemPrompt = buildAgentSystemPromptForCell(state, cell);
691
+ await runAgentLoop(prompt, {
692
+ provider,
693
+ layers: [layer],
694
+ systemPrompt,
695
+ maxIterations: 6,
696
+ signal: aborter.signal,
697
+ callbacks: {
698
+ onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
699
+ },
700
+ });
701
+ status.textContent = '✓ done';
702
+ } catch (err) {
703
+ status.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
704
+ } finally {
705
+ input.disabled = false;
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Build the WebMCP tool layer the agent sees. Tools mutate state via the
711
+ * shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
712
+ * exactly like a manual edit.
713
+ */
714
+ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
715
+ const idx = () => state.cells.findIndex((c) => c.id === cell.id);
716
+ const tools = [
717
+ {
718
+ name: 'get_current_cell',
719
+ description: 'Read the cell the user invoked the agent on (id, type, content, varname).',
720
+ inputSchema: { type: 'object', properties: {} },
721
+ execute: async () => ({
722
+ id: cell.id, type: cell.type,
723
+ content: cell.content,
724
+ varname: (cell as { varname?: string }).varname,
725
+ }),
726
+ },
727
+ {
728
+ name: 'list_cells',
729
+ description: 'List all cells in the notebook (id, type, short content preview).',
730
+ inputSchema: { type: 'object', properties: {} },
731
+ execute: async () => state.cells.map((c) => ({
732
+ id: c.id, type: c.type,
733
+ preview: (c.content ?? '').slice(0, 120),
734
+ varname: (c as { varname?: string }).varname,
735
+ })),
736
+ },
737
+ {
738
+ name: 'update_cell',
739
+ description: 'Replace the current cell content with new code/markdown. The cell type is preserved.',
740
+ inputSchema: {
741
+ type: 'object',
742
+ properties: { content: { type: 'string', description: 'New full content for the cell.' } },
743
+ required: ['content'],
744
+ },
745
+ execute: async (args: Record<string, unknown>) => {
746
+ const next = String(args.content ?? '');
747
+ const before = cell.content;
748
+ if (before === next) return { ok: true, unchanged: true };
749
+ // Snapshot prior content so logHistory can support revert.
750
+ cell.content = next;
751
+ cell.status = 'stale';
752
+ state.lastEditAt = Date.now();
753
+ // Manual history entry — reuse `edit` kind, summary mentions agent.
754
+ try {
755
+ (state.history ?? []).push({
756
+ ts: Date.now(), kind: 'edit',
757
+ summary: `agent edited ${cell.type} cell`,
758
+ snapshot: { cellId: cell.id, before, after: next },
759
+ } as never);
760
+ } catch { /* ignore — best-effort */ }
761
+ rerender();
762
+ return { ok: true };
763
+ },
764
+ },
765
+ {
766
+ name: 'insert_cell_after',
767
+ description: 'Insert a new cell directly after the current one. Use type=text for prose, sql for queries, js for scripts/widgets.',
768
+ inputSchema: {
769
+ type: 'object',
770
+ properties: {
771
+ type: { type: 'string', enum: ['text', 'sql', 'js'], description: '"text" for markdown prose, "sql" for a query, "js" for a script (use widget(name, params) to render a widget).' },
772
+ content: { type: 'string', description: 'Cell content.' },
773
+ varname: { type: 'string', description: 'Optional varname for sql/js cells — exposes the result as a variable to subsequent cells.' },
774
+ },
775
+ required: ['type', 'content'],
776
+ },
777
+ execute: async (args: Record<string, unknown>) => {
778
+ const t = String(args.type ?? '');
779
+ const type: NotebookCell['type'] = t === 'text' ? 'md' : t === 'sql' ? 'sql' : 'js';
780
+ const newCell: NotebookCell = {
781
+ id: uid(), type,
782
+ content: String(args.content ?? ''),
783
+ hideSource: false, hideResult: false, status: 'stale',
784
+ ...(args.varname ? { varname: String(args.varname) } : {}),
785
+ };
786
+ addImportedCells(state, [newCell], idx());
787
+ rerender();
788
+ return { ok: true, id: newCell.id };
789
+ },
790
+ },
791
+ {
792
+ name: 'list_widgets',
793
+ description: 'List widgets exposed by the connected WebMCP servers — pickable in JS cells via widget(name, params).',
794
+ inputSchema: { type: 'object', properties: {} },
795
+ execute: async () => {
796
+ const out: Array<{ server: string; name: string; description?: string }> = [];
797
+ for (const s of state.webmcpServers ?? []) {
798
+ for (const r of s.layer().recipes ?? []) out.push({ server: s.name, name: r.name, description: r.description });
799
+ }
800
+ return out;
801
+ },
802
+ },
803
+ ];
804
+ return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools, recipes: [] };
805
+ }
806
+
807
+ function buildAgentSystemPromptForCell(_state: NotebookState, cell: NotebookCell): string {
808
+ return [
809
+ 'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
810
+ 'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
811
+ `The current cell is of type "${cell.type}".`,
812
+ 'Workflow:',
813
+ ' 1. Call get_current_cell to read the cell content + varname.',
814
+ ' 2. If the user wants to TRANSFORM the existing cell (filter, refactor, fix), call update_cell.',
815
+ ' 3. If the user wants a FOLLOW-UP step (chart, summary, follow-up query), call insert_cell_after.',
816
+ ' 4. For widgets: call list_widgets first to discover available names, then write a JS cell with `return widget("<name>", { rows, ...params })`.',
817
+ 'Be terse. One or two tool calls is usually enough. Do not explain at length.',
818
+ ].join('\n');
819
+ }
820
+
449
821
  // ---------------------------------------------------------------------------
450
822
  // Result rendering — editorial flavour (serif headers, mono cells, discreet)
451
823
  // ---------------------------------------------------------------------------
@@ -471,7 +843,7 @@ function formatMs(ms: number): string {
471
843
  return (ms / 1000).toFixed(2) + 's';
472
844
  }
473
845
 
474
- function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
846
+ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null, stateRef?: NotebookState): void {
475
847
  const r = effectiveResult(cell, overlay) ?? cell.lastResult;
476
848
  el.innerHTML = '';
477
849
  if (!r) {
@@ -509,6 +881,22 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
509
881
  renderChart(chart, r.spec).catch(() => { /* fallback handled internally */ });
510
882
  return;
511
883
  }
884
+ if (r.kind === 'widget') {
885
+ const fallbackServers = stateRef?.webmcpServers ?? [];
886
+ for (const w of r.widgets) {
887
+ const host = document.createElement('div');
888
+ host.className = 'nb-widget-host';
889
+ el.appendChild(host);
890
+ try { mountWidget(host, w.name, w.params, fallbackServers); }
891
+ catch (err) {
892
+ const pre = document.createElement('pre');
893
+ pre.className = 'nbe-result-error';
894
+ pre.textContent = `widget "${w.name}" failed: ${String((err as { message?: unknown })?.message ?? err)}`;
895
+ host.appendChild(pre);
896
+ }
897
+ }
898
+ return;
899
+ }
512
900
  // table — editorial style: serif header row, mono cells, minimal chrome.
513
901
  const cols = r.columns && r.columns.length ? r.columns
514
902
  : (r.rows[0] ? Object.keys(r.rows[0]) : []);
@@ -4,7 +4,7 @@
4
4
  // Consumed by import-modals.ts and left-pane.ts.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { parseBody } from '@webmcp-auto-ui/sdk';
7
+ import { parseBody, parseWidgetDisplayCall } from '@webmcp-auto-ui/sdk';
8
8
  import { uid, defaultCellContent } from './shared.js';
9
9
  import type { NotebookCell, CellType } from './shared.js';
10
10
 
@@ -80,6 +80,21 @@ export function extractCellFromFence(lang: string, content: string): NotebookCel
80
80
  return { id: uid(), type: 'sql', content: sql.trim(), hideSource: false, hideResult: false };
81
81
  }
82
82
  }
83
+ // widget_display is a local WebMCP tool (autoui), not a remote MCP tool —
84
+ // route it to the JS sandbox `widget()` helper which captures and renders
85
+ // inline. callTool() would fail with "No MCP server exposes tool".
86
+ if (/^(?:[A-Za-z_]\w*_)?widget_display$/.test(name)) {
87
+ const parsed = parseWidgetDisplayCall(trimmed);
88
+ if (parsed) {
89
+ const paramsLiteral = JSON.stringify(parsed.params, null, 2);
90
+ return {
91
+ id: uid(),
92
+ type: 'js',
93
+ content: `return widget('${parsed.name}', ${paramsLiteral})`,
94
+ hideSource: false, hideResult: false,
95
+ };
96
+ }
97
+ }
83
98
  return {
84
99
  id: uid(),
85
100
  type: 'js',
@@ -71,7 +71,8 @@ function buildFrontmatter(state: NotebookState): string {
71
71
  const title = (state.title || '').trim();
72
72
  const description = extractDescription(state);
73
73
  const servers = collectEnabledServers();
74
- if (!title && !description && servers.length === 0) return '';
74
+ const webmcpServers = collectEnabledWebmcpServers();
75
+ if (!title && !description && servers.length === 0 && webmcpServers.length === 0) return '';
75
76
 
76
77
  const lines: string[] = ['---'];
77
78
  if (title) lines.push(`title: ${yamlQuote(title)}`);
@@ -83,10 +84,28 @@ function buildFrontmatter(state: NotebookState): string {
83
84
  lines.push(` url: ${yamlQuote(s.url)}`);
84
85
  }
85
86
  }
87
+ if (webmcpServers.length > 0) {
88
+ // YAML flow-style for compactness (registry ids, no spaces).
89
+ lines.push(`webmcp_servers: [${webmcpServers.map(yamlQuote).join(', ')}]`);
90
+ }
86
91
  lines.push('---', '');
87
92
  return lines.join('\n');
88
93
  }
89
94
 
95
+ /**
96
+ * Read canvas.enabledServerIds — the registry ids (e.g. 'autoui', 'd3',
97
+ * 'observable-plot') of bundled WebMCP servers active in this notebook.
98
+ * The viewer re-instantiates them from @webmcp-auto-ui/servers on load.
99
+ */
100
+ function collectEnabledWebmcpServers(): string[] {
101
+ try {
102
+ const ids = (canvasVanilla as { enabledServerIds?: string[] }).enabledServerIds ?? [];
103
+ return ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
90
109
  function extractDescription(state: NotebookState): string {
91
110
  for (const cell of state.cells) {
92
111
  if (cell.type !== 'md') continue;