@webmcp-auto-ui/ui 2.5.34 → 2.5.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.34",
3
+ "version": "2.5.36",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -19,6 +19,10 @@ export { default as MarkdownView } from './primitives/MarkdownView.svelte';
19
19
  export { default as CodeView } from './primitives/CodeView.svelte';
20
20
  export { renderMarkdown, highlightCode, createMarkdownRenderer } from './primitives/markdown-renderer.js';
21
21
 
22
+ // Recipe building blocks (used by RecipeModal and notebook recipe-viewer)
23
+ export { default as RecipeCodeBlock } from './recipe/RecipeCodeBlock.svelte';
24
+ export type { RecipeBlockAction } from './recipe/types.js';
25
+
22
26
  // Widgets are shipped as Svelte 5 custom elements — import the widget file
23
27
  // side-effect to register its tag (e.g. `import '@webmcp-auto-ui/ui/widgets/simple/stat.svelte';`
24
28
  // then use `<auto-stat data={spec}></auto-stat>`). `WidgetRenderer` does this for you.
@@ -0,0 +1,323 @@
1
+ <script lang="ts">
2
+ import type { McpMultiClient } from '@webmcp-auto-ui/core';
3
+ import type { RunResult } from '@webmcp-auto-ui/sdk';
4
+ import { runCode, estimateTokens } from '@webmcp-auto-ui/sdk';
5
+ import { highlightCode } from '../primitives/markdown-renderer.js';
6
+ import type { RecipeBlockAction } from './types.js';
7
+
8
+ interface Props {
9
+ code: string;
10
+ lang?: string;
11
+ /**
12
+ * Custom action buttons in the gutter. If omitted AND `onrun` is provided,
13
+ * a default Run button is rendered (back-compat with original flex behavior).
14
+ */
15
+ actions?: RecipeBlockAction[];
16
+ /**
17
+ * Back-compat: legacy flex usage. When set, a default Run button is rendered
18
+ * that calls runCode(code, lang) and forwards the result to this callback.
19
+ * Ignored when `actions` is provided.
20
+ */
21
+ onrun?: (payload: { code: string; lang: string; result: RunResult }) => void;
22
+ }
23
+
24
+ let {
25
+ code = '',
26
+ lang = 'text',
27
+ actions = undefined,
28
+ onrun,
29
+ }: Props = $props();
30
+
31
+ let editable = $state('');
32
+ let runStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
33
+ let elapsed = $state(0);
34
+ let liveTokens = $state(0);
35
+ let lastDuration = $state<number | undefined>(undefined);
36
+ let lastTokens = $state<number | undefined>(undefined);
37
+ let timerId: ReturnType<typeof setInterval> | undefined;
38
+ let doneResetId: ReturnType<typeof setTimeout> | undefined;
39
+
40
+ $effect(() => {
41
+ editable = code;
42
+ });
43
+
44
+ let highlightedHtml = $derived(highlightCode(editable, lang || 'plaintext'));
45
+ let preEl: HTMLPreElement | undefined = $state(undefined);
46
+ let taEl: HTMLTextAreaElement | undefined = $state(undefined);
47
+
48
+ function syncScroll() {
49
+ if (preEl && taEl) {
50
+ preEl.scrollTop = taEl.scrollTop;
51
+ preEl.scrollLeft = taEl.scrollLeft;
52
+ }
53
+ }
54
+
55
+ function formatTokens(n: number): string {
56
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
57
+ return `${n} tok`;
58
+ }
59
+
60
+ function startTimer(startAt: number) {
61
+ stopTimer();
62
+ timerId = setInterval(() => {
63
+ elapsed = Math.round(performance.now() - startAt);
64
+ liveTokens = estimateTokens(editable);
65
+ }, 60);
66
+ }
67
+
68
+ function stopTimer() {
69
+ if (timerId) {
70
+ clearInterval(timerId);
71
+ timerId = undefined;
72
+ }
73
+ }
74
+
75
+ async function handleRun() {
76
+ if (runStatus === 'running') return;
77
+ if (doneResetId) {
78
+ clearTimeout(doneResetId);
79
+ doneResetId = undefined;
80
+ }
81
+ runStatus = 'running';
82
+ elapsed = 0;
83
+ liveTokens = estimateTokens(editable);
84
+ const t0 = performance.now();
85
+ startTimer(t0);
86
+
87
+ const multi = (globalThis as unknown as { __multiMcp?: { multiClient: McpMultiClient } }).__multiMcp?.multiClient;
88
+ const result = await runCode(editable, lang, multi);
89
+
90
+ stopTimer();
91
+ lastDuration = result.durationMs;
92
+ lastTokens = result.tokens;
93
+ runStatus = result.status === 'error' ? 'error' : 'done';
94
+
95
+ onrun?.({ code: editable, lang, result });
96
+
97
+ if (runStatus === 'done') {
98
+ doneResetId = setTimeout(() => {
99
+ if (runStatus === 'done') runStatus = 'idle';
100
+ }, 1000);
101
+ }
102
+ }
103
+
104
+ // Resolve the action list: explicit actions OR back-compat single Run button.
105
+ const resolvedActions = $derived<RecipeBlockAction[]>(
106
+ actions && actions.length > 0
107
+ ? actions
108
+ : onrun
109
+ ? [{
110
+ icon: '▶',
111
+ label: 'Run',
112
+ onclick: () => handleRun(),
113
+ }]
114
+ : []
115
+ );
116
+
117
+ // For the single-action Run case, we render run-status icon + stats inline.
118
+ const isSingleRunAction = $derived(
119
+ !actions && !!onrun && resolvedActions.length === 1
120
+ );
121
+
122
+ function handleActionClick(a: RecipeBlockAction) {
123
+ a.onclick(editable, lang);
124
+ }
125
+ </script>
126
+
127
+ <div class="code-block">
128
+ <div class="gutter">
129
+ {#if isSingleRunAction}
130
+ <button
131
+ type="button"
132
+ class="run-btn {runStatus}"
133
+ onclick={() => handleRun()}
134
+ disabled={runStatus === 'running'}
135
+ title={runStatus === 'running' ? 'Running...' : 'Run'}
136
+ >
137
+ <span class="icon">
138
+ {#if runStatus === 'running'}
139
+ {@html '&#x25D0;'}
140
+ {:else if runStatus === 'done'}
141
+ {@html '&#x2713;'}
142
+ {:else if runStatus === 'error'}
143
+ !
144
+ {:else}
145
+ {@html '&#x25B6;'}
146
+ {/if}
147
+ </span>
148
+ {#if runStatus === 'running' || lastDuration !== undefined}
149
+ <span class="stats">
150
+ <span class="t">
151
+ {runStatus === 'running' ? `${elapsed}ms` : `${lastDuration}ms`}
152
+ </span>
153
+ <span class="tok">
154
+ {formatTokens(runStatus === 'running' ? liveTokens : (lastTokens ?? 0))}
155
+ </span>
156
+ </span>
157
+ {/if}
158
+ </button>
159
+ {:else}
160
+ {#each resolvedActions as a}
161
+ <button
162
+ type="button"
163
+ class="action-btn {a.variant ?? 'default'}"
164
+ onclick={() => handleActionClick(a)}
165
+ title={a.label ?? a.icon}
166
+ >
167
+ <span class="icon">{@html a.icon}</span>
168
+ </button>
169
+ {/each}
170
+ {/if}
171
+ </div>
172
+
173
+ <div class="editor-wrap">
174
+ {#if lang && lang !== 'text'}
175
+ <div class="lang-tag font-mono">{lang}</div>
176
+ {/if}
177
+ <pre bind:this={preEl} class="editor highlight-layer hljs font-mono" aria-hidden="true"><code class="hljs language-{lang || 'plaintext'}">{@html highlightedHtml}</code></pre>
178
+ <textarea
179
+ bind:this={taEl}
180
+ bind:value={editable}
181
+ onscroll={syncScroll}
182
+ spellcheck="false"
183
+ autocomplete="off"
184
+ rows={Math.min(Math.max(editable.split('\n').length, 3), 20)}
185
+ class="editor input-layer font-mono"
186
+ ></textarea>
187
+ </div>
188
+ </div>
189
+
190
+ <style>
191
+ .code-block {
192
+ display: flex;
193
+ align-items: stretch;
194
+ gap: 6px;
195
+ margin: 0.5rem 0;
196
+ }
197
+ .gutter {
198
+ display: flex;
199
+ flex-direction: column;
200
+ gap: 4px;
201
+ align-items: stretch;
202
+ }
203
+ .run-btn,
204
+ .action-btn {
205
+ display: flex;
206
+ flex-direction: column;
207
+ align-items: center;
208
+ justify-content: center;
209
+ gap: 4px;
210
+ min-width: 56px;
211
+ padding: 8px 6px;
212
+ border-radius: 0.375rem;
213
+ border: 1px solid rgba(255, 255, 255, 0.08);
214
+ background: #0d1117;
215
+ color: rgb(180, 180, 180);
216
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
217
+ font-size: 11px;
218
+ cursor: pointer;
219
+ transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
220
+ }
221
+ .run-btn:hover:not(:disabled),
222
+ .action-btn:hover:not(:disabled) {
223
+ background: #161b22;
224
+ color: #fff;
225
+ }
226
+ .run-btn:disabled,
227
+ .action-btn:disabled {
228
+ cursor: progress;
229
+ }
230
+ .run-btn .icon,
231
+ .action-btn .icon {
232
+ font-size: 14px;
233
+ line-height: 1;
234
+ }
235
+ .run-btn.running .icon {
236
+ animation: spin 0.9s linear infinite;
237
+ }
238
+ .run-btn.done,
239
+ .action-btn.success {
240
+ color: rgb(74, 222, 128);
241
+ border-color: rgba(74, 222, 128, 0.4);
242
+ }
243
+ .run-btn.error,
244
+ .action-btn.error {
245
+ color: rgb(248, 113, 113);
246
+ border-color: rgba(248, 113, 113, 0.4);
247
+ }
248
+ .run-btn .stats {
249
+ display: flex;
250
+ flex-direction: column;
251
+ gap: 1px;
252
+ font-size: 9px;
253
+ color: rgb(160, 160, 160);
254
+ line-height: 1.2;
255
+ }
256
+ .run-btn .stats .t { font-weight: 600; }
257
+ .run-btn .stats .tok { opacity: 0.8; }
258
+ @keyframes spin {
259
+ from { transform: rotate(0deg); }
260
+ to { transform: rotate(360deg); }
261
+ }
262
+
263
+ .editor-wrap {
264
+ flex: 1;
265
+ min-width: 0;
266
+ position: relative;
267
+ }
268
+ .lang-tag {
269
+ position: absolute;
270
+ top: 4px;
271
+ right: 8px;
272
+ font-size: 9px;
273
+ color: rgba(255, 255, 255, 0.35);
274
+ text-transform: lowercase;
275
+ pointer-events: none;
276
+ }
277
+ .editor {
278
+ display: block;
279
+ width: 100%;
280
+ box-sizing: border-box;
281
+ background: #0d1117;
282
+ color: rgb(220, 220, 220);
283
+ border: 1px solid rgba(255, 255, 255, 0.08);
284
+ border-radius: 0.375rem;
285
+ padding: 0.7rem;
286
+ font-size: 0.7rem;
287
+ line-height: 1.5;
288
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
289
+ white-space: pre;
290
+ tab-size: 2;
291
+ margin: 0;
292
+ }
293
+ .highlight-layer {
294
+ position: absolute;
295
+ inset: 0;
296
+ overflow: auto;
297
+ pointer-events: none;
298
+ border-color: transparent;
299
+ }
300
+ .highlight-layer :global(code.hljs) {
301
+ background: transparent;
302
+ padding: 0;
303
+ }
304
+ .input-layer {
305
+ position: relative;
306
+ color: transparent;
307
+ caret-color: rgb(220, 220, 220);
308
+ background: transparent;
309
+ resize: vertical;
310
+ outline: none;
311
+ overflow: auto;
312
+ }
313
+ .input-layer::selection {
314
+ color: transparent;
315
+ background: rgba(96, 165, 250, 0.35);
316
+ }
317
+ .input-layer:focus {
318
+ border-color: rgba(96, 165, 250, 0.45);
319
+ }
320
+ .editor-wrap {
321
+ min-height: 0;
322
+ }
323
+ </style>
@@ -0,0 +1,10 @@
1
+ export interface RecipeBlockAction {
2
+ /** Glyph or HTML entity. Examples: '▶', '+', '⧉', '✓' */
3
+ icon: string;
4
+ /** Tooltip text. Defaults to icon. */
5
+ label?: string;
6
+ /** Visual variant. */
7
+ variant?: 'default' | 'success' | 'error';
8
+ /** Click handler. Receives the (possibly edited) code + lang. */
9
+ onclick: (code: string, lang: string) => void;
10
+ }
@@ -1,13 +1,14 @@
1
1
  import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
2
- import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor } from '../shared.js';
2
+ import { findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
3
+ import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor, DataServerTool } from '../shared.js';
3
4
 
4
5
  const PATTERN_PRIMARY = /^.*query_sql$/i;
5
6
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
6
7
 
7
- function findSqlTool(servers: DataServerDescriptor[]): string | null {
8
+ function findSqlTool(servers: DataServerDescriptor[]): DataServerTool | null {
8
9
  for (const p of [PATTERN_PRIMARY, PATTERN_FALLBACK]) {
9
10
  for (const srv of servers) {
10
- for (const t of srv.tools ?? []) if (p.test(t.name)) return t.name;
11
+ for (const t of srv.tools ?? []) if (p.test(t.name)) return t;
11
12
  }
12
13
  }
13
14
  return null;
@@ -16,16 +17,26 @@ function findSqlTool(servers: DataServerDescriptor[]): string | null {
16
17
  export function createSqlExecutor(getServers: () => DataServerDescriptor[]): CellExecutor {
17
18
  return async (ctx: CellExecContext): Promise<CellResult> => {
18
19
  const startedAt = Date.now();
19
- const toolName = findSqlTool(getServers());
20
- if (!toolName) {
20
+ const tool = findSqlTool(getServers());
21
+ if (!tool) {
21
22
  return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
22
23
  }
23
24
  const sql = (ctx.cell.content ?? '').trim();
24
25
  if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
25
26
 
27
+ // Build args from the tool's inputSchema:
28
+ // 1. Pick the code-carrying param (query / sql / statement / ...) via findCodeParamName.
29
+ // 2. Auto-infer required params (e.g. `schema` enum from FROM/JOIN regex).
30
+ // 3. Merge cell-level overrides from cell.args (parsed from `-- @meta {...}` line).
31
+ const codeParam = findCodeParamName(tool.inputSchema) ?? 'sql';
32
+ const auto = buildToolArgs(tool.inputSchema, codeParam, sql, 'sql');
33
+ const args: Record<string, unknown> = { ...auto, ...(ctx.cell.args ?? {}) };
34
+ // Code param is owned by the cell content, never overridable via @meta
35
+ args[codeParam] = sql;
36
+
26
37
  let raw: unknown;
27
38
  try {
28
- raw = await callToolViaPostMessage(toolName, { sql });
39
+ raw = await callToolViaPostMessage(tool.name, args);
29
40
  } catch (err) {
30
41
  return { ok: false, error: String((err as { message?: unknown })?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - startedAt };
31
42
  }
@@ -16,7 +16,6 @@
16
16
  // Side-effect import: registers <auto-import-modal> custom element
17
17
  import './import-modal.svelte';
18
18
 
19
- import { renderMarkdownWithInjectButtons } from './prose.js';
20
19
  import { extractCellsFromRecipe, extractCellsFromTool, extractCellFromFence } from './resource-extractor.js';
21
20
  import type { NotebookCell } from './shared.js';
22
21
  import type { McpToolLike } from './resource-extractor.js';
@@ -150,9 +149,6 @@ export function openRecipeViewerModal(
150
149
 
151
150
  _cleanup?.();
152
151
 
153
- // We'll hold a reference to the prose renderer's destroy fn.
154
- let proseDestroy: (() => void) | null = null;
155
-
156
152
  const handler = (e: CustomEvent) => {
157
153
  const { action, payload } = e.detail ?? {};
158
154
 
@@ -172,8 +168,6 @@ export function openRecipeViewerModal(
172
168
 
173
169
  if (action === 'inject-all' || action === 'close') {
174
170
  el.removeEventListener('widget:interact', handler as EventListener);
175
- proseDestroy?.();
176
- proseDestroy = null;
177
171
  _cleanup = null;
178
172
  }
179
173
  };
@@ -181,27 +175,9 @@ export function openRecipeViewerModal(
181
175
  el.addEventListener('widget:interact', handler as EventListener);
182
176
  _cleanup = () => {
183
177
  el.removeEventListener('widget:interact', handler as EventListener);
184
- proseDestroy?.();
185
- proseDestroy = null;
186
178
  };
187
179
 
188
180
  el.openModal({ mode: 'recipe-viewer', recipe });
189
-
190
- // After the CE opens, inject the rendered markdown into [data-role="render"].
191
- // requestAnimationFrame ensures Svelte has rendered the modal DOM.
192
- requestAnimationFrame(() => {
193
- const renderTarget = el.querySelector('[data-role="render"]') as HTMLElement | null;
194
- if (!renderTarget) return;
195
- const { root, destroy } = renderMarkdownWithInjectButtons(
196
- recipe.body ?? '',
197
- ({ lang, content }) => {
198
- const cell = extractCellFromFence(lang, content);
199
- onInjectCell(cell);
200
- },
201
- );
202
- renderTarget.appendChild(root);
203
- proseDestroy = destroy;
204
- });
205
181
  }
206
182
 
207
183
  // ---------------------------------------------------------------------------
@@ -19,6 +19,9 @@
19
19
 
20
20
  import { filterRecipes, sortRecipes, WEBMCP_RECIPES } from '@webmcp-auto-ui/agent';
21
21
  import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
22
+ import { parseBody } from '@webmcp-auto-ui/sdk';
23
+ import MarkdownView from '../../primitives/MarkdownView.svelte';
24
+ import RecipeCodeBlock from '../../recipe/RecipeCodeBlock.svelte';
22
25
 
23
26
  // ---------------------------------------------------------------------------
24
27
  // Types (inlined to avoid import-modals.ts cycle)
@@ -90,6 +93,10 @@
90
93
  const mode = $derived(data?.mode ?? 'add-md');
91
94
  const recipe = $derived(data?.recipe);
92
95
  const tool = $derived(data?.tool);
96
+ const recipeSegments = $derived(parseBody(recipe?.body ?? ''));
97
+ const recipeCellCount = $derived(
98
+ recipeSegments.length + ((recipe?.name || recipe?.description) ? 1 : 0),
99
+ );
93
100
 
94
101
  // ---------------------------------------------------------------------------
95
102
  // Public API — called by wrapper functions
@@ -255,25 +262,13 @@
255
262
  // Handlers — recipe-viewer
256
263
  // ---------------------------------------------------------------------------
257
264
 
258
- // prose rendering is done server-side for recipe-viewer; we re-use the
259
- // renderMarkdownWithInjectButtons helper injected via slot or data.
260
- // Since this is a CE, we render as raw HTML via {@html} after sanitizing.
261
- // The inject buttons are wired via event delegation on the section.
262
-
263
265
  function handleInjectAll() {
264
- // extractCellsFromRecipe is a pure function from resource-extractor.ts —
265
- // but import would create a cycle ui<->notebook. We emit the raw body
266
- // and let the consumer (notebook.ts) call extractCellsFromRecipe.
266
+ // Notebook owns extractCellsFromRecipe to avoid a ui<->notebook cycle.
267
267
  emitInteract('inject-all', { recipe: recipe });
268
268
  closeModal();
269
269
  }
270
270
 
271
- function handleInjectFence(e: Event) {
272
- // Delegated click on .nb-md-fence-inject buttons rendered via {@html}
273
- const btn = (e.target as HTMLElement).closest('[data-fence-inject]') as HTMLElement | null;
274
- if (!btn) return;
275
- const lang = btn.dataset.lang ?? '';
276
- const content = btn.dataset.content ?? '';
271
+ function handleInjectFence(content: string, lang: string) {
277
272
  emitInteract('inject-fence', { lang, content });
278
273
  }
279
274
 
@@ -426,12 +421,30 @@
426
421
  {#if recipe?.description}<p>{recipe.description}</p>{/if}
427
422
  {#if recipe?.serverName}<span class="nb-imp-recipe-srv">{recipe.serverName}</span>{/if}
428
423
  </div>
429
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
430
- <section class="nb-imp-body nb-imp-body-recipe" data-role="render" onclick={handleInjectFence}>
431
- <!-- Content injected imperatively by notebook.ts via renderMarkdownWithInjectButtons -->
424
+ <section class="nb-imp-body nb-imp-body-recipe">
425
+ {#each recipeSegments as seg, i (i)}
426
+ {#if seg.type === 'markdown'}
427
+ <MarkdownView source={seg.content} />
428
+ {:else}
429
+ <RecipeCodeBlock
430
+ code={seg.content}
431
+ lang={seg.lang ?? 'text'}
432
+ actions={[{
433
+ icon: '+',
434
+ label: 'Inject as cell',
435
+ onclick: handleInjectFence,
436
+ }]}
437
+ />
438
+ {/if}
439
+ {/each}
432
440
  </section>
433
441
  <footer class="nb-imp-foot">
434
- <button type="button" class="nb-imp-btn" onclick={handleInjectAll}>Inject all cells</button>
442
+ <button
443
+ type="button"
444
+ class="nb-imp-btn nb-imp-primary"
445
+ onclick={handleInjectAll}
446
+ disabled={recipeCellCount === 0}
447
+ >Inject all cells{recipeCellCount > 0 ? ` (${recipeCellCount})` : ''}</button>
435
448
  </footer>
436
449
 
437
450
  <!-- ================================================================ -->
@@ -691,48 +704,4 @@
691
704
  font-size: 11.5px;
692
705
  }
693
706
 
694
- /* ---- Styles for prose rendered inside recipe-viewer (delegated via {@html} in consumers) ---- */
695
- :global(.nb-md-render) { font-size: 13px; line-height: 1.5; }
696
- :global(.nb-md-render h1),
697
- :global(.nb-md-render h2),
698
- :global(.nb-md-render h3) { margin: 12px 0 6px; }
699
- :global(.nb-md-render p) { margin: 6px 0; }
700
- :global(.nb-md-render ul),
701
- :global(.nb-md-render ol) { margin: 6px 0 6px 20px; }
702
- :global(.nb-md-render pre) {
703
- background: var(--color-surface2, #f4f4f5);
704
- padding: 10px 12px;
705
- border-radius: 6px;
706
- overflow-x: auto;
707
- font-size: 12px;
708
- }
709
- :global(.nb-md-render code) { font-family: var(--font-mono, monospace); font-size: 12px; }
710
- :global(.nb-md-fence) {
711
- border: 1px solid var(--color-border, #e4e4e7);
712
- border-radius: 8px;
713
- margin: 10px 0;
714
- overflow: hidden;
715
- }
716
- :global(.nb-md-fence-head) {
717
- display: flex;
718
- align-items: center;
719
- gap: 10px;
720
- padding: 6px 10px;
721
- background: var(--color-surface2, #f4f4f5);
722
- font-size: 11px;
723
- color: var(--color-text2, #666);
724
- border-bottom: 1px solid var(--color-border, #e4e4e7);
725
- }
726
- :global(.nb-md-fence-lang) { font-family: monospace; flex: 1; }
727
- :global(.nb-md-fence-inject) {
728
- background: var(--color-accent, #6a55ff);
729
- color: #fff;
730
- border: 0;
731
- border-radius: 4px;
732
- padding: 3px 9px;
733
- font-size: 11px;
734
- cursor: pointer;
735
- }
736
- :global(.nb-md-fence-inject:hover) { filter: brightness(1.08); }
737
- :global(.nb-md-fence pre) { margin: 0; border-radius: 0; background: transparent; }
738
707
  </style>
@@ -74,7 +74,6 @@ export function mountLeftPane(
74
74
  <header class="nb-lp-srv-head">
75
75
  <span class="nb-lp-srv-dot"></span>
76
76
  <span class="nb-lp-srv-name">${escapeHtml(srv.name)}</span>
77
- <span class="nb-lp-srv-meta">${(srv.recipes?.length ?? 0)} recipes · ${(srv.tools?.length ?? 0)} tools</span>
78
77
  </header>
79
78
  <div class="nb-lp-srv-groups">
80
79
  ${srv.recipes?.length ? `
@@ -141,19 +140,39 @@ export function mountLeftPane(
141
140
  let body = text;
142
141
  try {
143
142
  const parsed = JSON.parse(text);
144
- // Recipe servers return either { content: "..." } (legacy) or
145
- // { name, description, body, ... } (autoui-style). Pick whichever
146
- // string field carries the markdown body, in priority order.
147
143
  if (parsed && typeof parsed === 'object') {
148
- if (typeof parsed.body === 'string') body = parsed.body;
149
- else if (typeof parsed.content === 'string') body = parsed.content;
150
- else if (typeof parsed.markdown === 'string') body = parsed.markdown;
144
+ const candidates = [
145
+ parsed.body,
146
+ parsed.content,
147
+ parsed.markdown,
148
+ parsed.recipe?.body,
149
+ parsed.recipe?.content,
150
+ parsed.recipe?.markdown,
151
+ parsed.result?.body,
152
+ parsed.result?.content,
153
+ parsed.data?.body,
154
+ parsed.data?.content,
155
+ ];
156
+ const hit = candidates.find((c) => typeof c === 'string' && c.trim().length > 0);
157
+ if (hit) {
158
+ body = hit;
159
+ } else {
160
+ console.warn(
161
+ `[notebook] get_recipe(${srv.name}/${r.name}): no recognized body key in parsed JSON. Keys:`,
162
+ Object.keys(parsed),
163
+ );
164
+ }
151
165
  }
152
166
  } catch { /* not JSON, use raw text */ }
153
167
  imported.body = body;
154
168
  recipeBodyCache.set(key, body);
155
169
  }
156
- } catch { /* pass empty body to viewer */ }
170
+ } catch (err) {
171
+ console.warn(`[notebook] get_recipe(${srv.name}/${r.name}) failed:`, err);
172
+ }
173
+ }
174
+ if (!imported.body) {
175
+ imported.body = `> ⚠ Failed to load recipe body from \`${srv.name}\`. The server returned no usable content for \`${r.name}\`.`;
157
176
  }
158
177
  openRecipeViewerModal(imported, (cell) => handlers.onInjectCells([cell]));
159
178
  }
@@ -236,7 +255,6 @@ function injectLeftPaneStyles() {
236
255
  flex-shrink: 0;
237
256
  }
238
257
  .nb-lp-srv-name { font-weight: 600; color: var(--color-text1, #111); }
239
- .nb-lp-srv-meta { margin-left: auto; font-family: monospace; font-size: 10.5px; }
240
258
  .nb-lp-group > summary {
241
259
  cursor: pointer; padding: 4px 2px; font-size: 11px;
242
260
  color: var(--color-text2, #666); font-family: monospace;
@@ -21,7 +21,6 @@
21
21
  export interface NotebookData {
22
22
  id?: string;
23
23
  title?: string;
24
- kicker?: string;
25
24
  mode?: 'edit' | 'view';
26
25
  autoRun?: boolean;
27
26
  hideLiveToggle?: boolean;
@@ -35,10 +35,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
35
35
  title: data.title as string ?? 'Untitled notebook',
36
36
  mode: (data.mode as any) ?? 'edit',
37
37
  cells: data.cells as any,
38
- kicker: (data.kicker as string) ?? undefined,
39
38
  autoRun: (data as any).autoRun === true,
40
39
  });
41
- if (!state.kicker) state.kicker = (data.kicker as string) ?? 'untitled';
42
40
 
43
41
  // Live mode runtime overlay (created lazily). Never mutates state.
44
42
  let overlay: RuntimeOverlay | null = null;
@@ -56,7 +54,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
56
54
  <div class="nbe-leftpane-slot"></div>
57
55
  <div class="nbe-shell">
58
56
  <div class="nbe-kicker">
59
- <input class="nbe-kicker-input" value="${escapeAttr(state.kicker || '')}" placeholder="kicker…">
60
57
  <span class="nbe-live-toggle-slot"></span>
61
58
  <div class="nb-mode-switch" style="margin-left:auto;">
62
59
  <button class="nb-mode-edit nb-on">edit</button>
@@ -259,10 +256,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
259
256
  footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
260
257
  onPublished: () => rerender(),
261
258
  });
262
- (shell.querySelector('.nbe-kicker-input') as HTMLInputElement).addEventListener('input', (e) => {
263
- state.kicker = (e.target as HTMLInputElement).value;
264
- state.lastEditAt = Date.now();
265
- });
266
259
  (shell.querySelector('.nbe-title') as HTMLInputElement).addEventListener('input', (e) => {
267
260
  state.title = (e.target as HTMLInputElement).value;
268
261
  state.lastEditAt = Date.now();
@@ -623,16 +616,6 @@ function injectLayoutStyles(): void {
623
616
  letter-spacing: 0.1em; text-transform: uppercase;
624
617
  margin-bottom: 14px;
625
618
  }
626
- .nbe-kicker-input {
627
- flex: 0 0 auto; min-width: 120px;
628
- background: transparent; border: 1px dashed transparent;
629
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
630
- font-size: 11px; color: var(--color-text2);
631
- letter-spacing: 0.1em; text-transform: uppercase;
632
- padding: 2px 4px; border-radius: 3px; outline: none;
633
- }
634
- .nbe-kicker-input:focus { border-color: var(--color-border); background: var(--color-bg); color: var(--color-text1); }
635
- .nb-root.nb-view-mode .nbe-kicker-input { pointer-events: none; }
636
619
  .nbe-title {
637
620
  font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
638
621
  font-size: 30px; font-weight: 500;
@@ -650,9 +633,13 @@ function injectLayoutStyles(): void {
650
633
  .nbe-handle { position: absolute; left: 0; top: 6px; }
651
634
  .nbe-del-abs {
652
635
  position: absolute; top: 4px; right: 4px;
653
- opacity: 0; transition: opacity 0.15s;
636
+ opacity: 0.65; transition: opacity 0.15s;
637
+ width: 26px; height: 26px;
638
+ font-size: 16px; line-height: 1;
639
+ display: inline-flex; align-items: center; justify-content: center;
640
+ padding: 0;
641
+ z-index: 2;
654
642
  }
655
- .nbe-cell:hover .nbe-del-abs { opacity: 0.5; }
656
643
  .nbe-del-abs:hover { opacity: 1 !important; }
657
644
 
658
645
  .nbe-prose {
@@ -711,7 +698,7 @@ function injectLayoutStyles(): void {
711
698
  }
712
699
  .nbe-cell-head {
713
700
  display: flex; align-items: center; gap: 8px;
714
- padding: 7px 12px;
701
+ padding: 7px 44px 7px 12px;
715
702
  border-bottom: 1px solid var(--color-border);
716
703
  font-family: var(--font-mono, 'IBM Plex Mono', monospace);
717
704
  font-size: 10px; color: var(--color-text2);
@@ -1,7 +1,6 @@
1
1
  // @ts-nocheck
2
2
  // ---------------------------------------------------------------------------
3
3
  // Lightweight markdown renderer + allowlist sanitizer for notebook prose cells.
4
- // Also: renderMarkdownWithInjectButtons — used by recipe viewer modal.
5
4
  // No external dependencies.
6
5
  // ---------------------------------------------------------------------------
7
6
 
@@ -546,80 +545,3 @@ function updateEmptyState(host: HTMLElement): void {
546
545
  }
547
546
  }
548
547
 
549
- // ---------------------------------------------------------------------------
550
- // Renderer with inject buttons — used by recipe viewer modal.
551
- // Each fenced code block gets an "↳ inject" button next to it.
552
- // ---------------------------------------------------------------------------
553
-
554
- export interface InjectFenceEvent {
555
- lang: string;
556
- content: string;
557
- }
558
-
559
- /**
560
- * Render markdown into a container. Each fenced code block is rendered with
561
- * an "↳ inject" button; clicking it calls onInject({lang, content}).
562
- * Returns a cleanup function.
563
- */
564
- export function renderMarkdownWithInjectButtons(
565
- body: string,
566
- onInject: (e: InjectFenceEvent) => void,
567
- ): { root: HTMLElement; destroy: () => void } {
568
- const root = document.createElement('div');
569
- root.className = 'nb-md-render';
570
-
571
- const lines = (body || '').replace(/\r\n/g, '\n').split('\n');
572
- const buf: string[] = [];
573
- let i = 0;
574
- const cleanups: Array<() => void> = [];
575
-
576
- const flushProse = () => {
577
- if (buf.length) {
578
- const chunk = buf.join('\n');
579
- const p = document.createElement('div');
580
- p.innerHTML = renderProse(chunk);
581
- root.appendChild(p);
582
- buf.length = 0;
583
- }
584
- };
585
-
586
- while (i < lines.length) {
587
- const line = lines[i];
588
- if (/^```/.test(line)) {
589
- flushProse();
590
- const lang = line.replace(/^```/, '').trim().toLowerCase() || 'text';
591
- const code: string[] = [];
592
- i++;
593
- while (i < lines.length && !/^```/.test(lines[i])) {
594
- code.push(lines[i]);
595
- i++;
596
- }
597
- i++; // closing fence
598
- const content = code.join('\n');
599
-
600
- const block = document.createElement('div');
601
- block.className = 'nb-md-fence';
602
- block.innerHTML = `
603
- <div class="nb-md-fence-head">
604
- <span class="nb-md-fence-lang">${escapeHtml(lang)}</span>
605
- <button type="button" class="nb-md-fence-inject">↳ inject</button>
606
- </div>
607
- <pre class="hljs-pre"><code class="hljs language-${escapeHtml(lang)}">${highlightCode(content, lang)}</code></pre>
608
- `;
609
- const btn = block.querySelector('.nb-md-fence-inject') as HTMLButtonElement;
610
- const handler = () => onInject({ lang, content });
611
- btn.addEventListener('click', handler);
612
- cleanups.push(() => btn.removeEventListener('click', handler));
613
- root.appendChild(block);
614
- continue;
615
- }
616
- buf.push(line);
617
- i++;
618
- }
619
- flushProse();
620
-
621
- return {
622
- root,
623
- destroy: () => cleanups.forEach((f) => f()),
624
- };
625
- }
@@ -11,9 +11,6 @@ schema:
11
11
  mode:
12
12
  type: string
13
13
  enum: [edit, view]
14
- kicker:
15
- type: string
16
- description: Small uppercase label above the title (e.g. "analysis", "memo", "brief"). Editable inline. Defaults to "untitled".
17
14
  hideLiveToggle:
18
15
  type: boolean
19
16
  default: false
@@ -52,7 +49,6 @@ The distinguishing feature: prose paragraphs and code cells share a single order
52
49
  ```
53
50
  widget_display({name: "notebook", params: {
54
51
  title: "Q3 observations",
55
- kicker: "memo",
56
52
  cells: [
57
53
  {type: "md", content: "This memo covers the highlights of last quarter."},
58
54
  {type: "md", content: "We first look at revenue, then at churn."},
@@ -74,7 +70,6 @@ The distinguishing feature: prose paragraphs and code cells share a single order
74
70
  ## Notes
75
71
 
76
72
  - The serif font (EB Garamond, with Georgia fallback) applies only to prose content inside this widget — it signals "publication" the moment the user sees it.
77
- - The **kicker** above the title ("analysis", "memo", "internal") is editable inline — click to rename. Keep it short.
78
73
  - Prose cells are rendered via an HTML-sanitizing markdown pipeline: markdown syntax is resolved, unsafe tags are stripped (XSS closed), `<mark>` and other editorial tags are preserved.
79
74
  - The footer exposes a single `share` button.
80
75
  - Run / Stop controls are at the left of each code cell's header, same as the other notebook layouts.
@@ -112,7 +107,6 @@ An editorial piece earns its weight when the prose is anchored to real material.
112
107
  name: 'notebook',
113
108
  params: {
114
109
  title: '...',
115
- kicker: 'memo',
116
110
  cells: [...],
117
111
  servers: [{ name: 'tricoteuses', url: 'https://...', kind: 'data' }]
118
112
  }
@@ -26,6 +26,23 @@ const LANG_TO_TYPE: Record<string, CellType> = {
26
26
  * whenever such a fence is mapped so the user gets a hint. */
27
27
  const TS_LIKE_LANGS = new Set(['ts', 'typescript']);
28
28
 
29
+ /**
30
+ * Peel a leading `-- @meta {...}` (SQL) or `// @meta {...}` (JS) line from the
31
+ * cell content and return the parsed JSON args plus the remaining content.
32
+ * Round-trip with serializeCellMeta in share-handlers.ts.
33
+ */
34
+ export function peelMetaComment(content: string): { content: string; args?: Record<string, unknown> } {
35
+ const m = /^[ \t]*(?:--|\/\/)[ \t]*@meta[ \t]+(\{[\s\S]*?\})[ \t]*\r?\n?/.exec(content);
36
+ if (!m) return { content };
37
+ try {
38
+ const parsed = JSON.parse(m[1]);
39
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
40
+ return { content: content.slice(m[0].length), args: parsed as Record<string, unknown> };
41
+ }
42
+ } catch { /* invalid JSON → ignore, keep line in content */ }
43
+ return { content };
44
+ }
45
+
29
46
  export function fenceLangToCellType(lang: string): CellType | null {
30
47
  const key = (lang || '').toLowerCase().trim();
31
48
  if (TS_LIKE_LANGS.has(key)) {
@@ -45,7 +62,10 @@ export function fenceLangToCellType(lang: string): CellType | null {
45
62
  export function extractCellFromFence(lang: string, content: string): NotebookCell {
46
63
  const cellType = fenceLangToCellType(lang);
47
64
  if (cellType) {
48
- return { id: uid(), type: cellType, content: content.trim(), hideSource: false, hideResult: false };
65
+ const { content: stripped, args } = peelMetaComment(content.trim());
66
+ const cell: NotebookCell = { id: uid(), type: cellType, content: stripped, hideSource: false, hideResult: false };
67
+ if (args) cell.args = args;
68
+ return cell;
49
69
  }
50
70
  // Detect pseudo-code MCP tool calls like: query_sql({sql: "..."})
51
71
  // Only attempted when the fence language is unknown/text (cellType === null).
@@ -36,7 +36,11 @@ function serializeToMarkdown(state: NotebookState): string {
36
36
  } else {
37
37
  const lang = cell.type === 'sql' ? 'sql' : 'js';
38
38
  const varname = cell.varname ? ` // → ${cell.varname}` : '';
39
- parts.push('```' + lang + varname, cell.content.trim(), '```', '');
39
+ const commentPrefix = cell.type === 'sql' ? '--' : '//';
40
+ const metaLine = cell.args && Object.keys(cell.args).length > 0
41
+ ? `${commentPrefix} @meta ${JSON.stringify(cell.args)}\n`
42
+ : '';
43
+ parts.push('```' + lang + varname, metaLine + cell.content.trim(), '```', '');
40
44
  }
41
45
  }
42
46
  return parts.join('\n').trim() + '\n';
@@ -191,7 +195,6 @@ function minify(state: NotebookState): Record<string, unknown> {
191
195
  id: state.id,
192
196
  title: state.title,
193
197
  mode: state.mode,
194
- kicker: state.kicker,
195
198
  cells: state.cells.map((c: NotebookCell) => ({
196
199
  id: c.id,
197
200
  type: c.type,
@@ -51,6 +51,7 @@ export interface NotebookCell {
51
51
  status?: 'fresh' | 'stale';
52
52
  comment?: { who: string; when: string; body: string } | null;
53
53
  lastResult?: CellResult;
54
+ args?: Record<string, unknown>; // override of tool args, parsed from `@meta {...}` leading comment
54
55
  }
55
56
 
56
57
  export interface NotebookState {
@@ -62,7 +63,6 @@ export interface NotebookState {
62
63
  scope: Record<string, unknown>;
63
64
  executors: CellExecutors;
64
65
  lastEditAt: number;
65
- kicker?: string;
66
66
  publishedSlug?: string;
67
67
  publishedToken?: string;
68
68
  /**
@@ -168,24 +168,46 @@ export function defaultCellContent(type: CellType): string {
168
168
  // ---------------------------------------------------------------------------
169
169
 
170
170
  export function createState(initial?: Partial<NotebookState>): NotebookState {
171
- return {
172
- id: initial?.id ?? uid(),
173
- title: initial?.title ?? 'Untitled notebook',
174
- mode: initial?.mode ?? 'edit',
175
- cells: initial?.cells ?? [
176
- { id: uid(), type: 'md', content: '### Untitled notebook\n\nAdd some context here.', hideSource: false, hideResult: false },
171
+ const title = initial?.title ?? 'Untitled notebook';
172
+ const cells = stripDuplicateTitleHeading(
173
+ initial?.cells ?? [
174
+ { id: uid(), type: 'md', content: 'Add some context here.', hideSource: false, hideResult: false },
177
175
  { id: uid(), type: 'sql', content: 'select *\nfrom source\nlimit 5', varname: 'rows', hideSource: false, hideResult: false, status: 'fresh' },
178
176
  { id: uid(), type: 'js', content: 'console.log(rows)', hideSource: false, hideResult: false, status: 'stale' },
179
177
  ],
178
+ title,
179
+ );
180
+ return {
181
+ id: initial?.id ?? uid(),
182
+ title,
183
+ mode: initial?.mode ?? 'edit',
184
+ cells,
180
185
  history: initial?.history ?? [],
181
186
  scope: initial?.scope ?? {},
182
187
  executors: initial?.executors ?? {},
183
188
  lastEditAt: initial?.lastEditAt ?? Date.now(),
184
- kicker: initial?.kicker,
185
189
  autoRun: initial?.autoRun ?? false,
186
190
  };
187
191
  }
188
192
 
193
+ // If the first md cell opens with a heading whose plain text matches the
194
+ // notebook title, drop that heading line so the title is not rendered twice.
195
+ function stripDuplicateTitleHeading(cells: NotebookCell[], title: string): NotebookCell[] {
196
+ if (!title || cells.length === 0) return cells;
197
+ const first = cells[0];
198
+ if (first.type !== 'md' || typeof first.content !== 'string') return cells;
199
+ const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
200
+ const lines = first.content.split('\n');
201
+ let i = 0;
202
+ while (i < lines.length && lines[i].trim() === '') i++;
203
+ if (i >= lines.length) return cells;
204
+ const m = lines[i].match(/^#{1,6}\s+(.+?)\s*$/);
205
+ if (!m || norm(m[1]) !== norm(title)) return cells;
206
+ let drop = i + 1;
207
+ while (drop < lines.length && lines[drop].trim() === '') drop++;
208
+ return [{ ...first, content: lines.slice(drop).join('\n') }, ...cells.slice(1)];
209
+ }
210
+
189
211
  // ---------------------------------------------------------------------------
190
212
  // Live mode (autoRun) — RuntimeOverlay + helpers
191
213
  //
@@ -641,6 +663,7 @@ export function tickRunningCell(cell: NotebookCell, elapsedEl: HTMLElement, onDo
641
663
  export interface DataServerTool {
642
664
  name: string;
643
665
  description?: string;
666
+ inputSchema?: unknown;
644
667
  }
645
668
 
646
669
  export interface DataServerRecipe {
@@ -715,7 +738,7 @@ export interface PublishControlsOptions {
715
738
  onPublished?: (info: { slug: string; url: string; updated: boolean }) => void;
716
739
  /** Optional toast function — falls back to internal toast helper if absent. */
717
740
  toast?: (message: string, isError?: boolean) => void;
718
- /** Minimal projection of the state sent to the server. If absent, sends { id, title, kicker, mode, cells }. */
741
+ /** Minimal projection of the state sent to the server. If absent, sends { id, title, mode, cells }. */
719
742
  serializeState?: (state: NotebookState) => Record<string, unknown>;
720
743
  }
721
744
 
@@ -819,7 +842,6 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
819
842
  : {
820
843
  id: state.id,
821
844
  title: state.title,
822
- kicker: state.kicker,
823
845
  mode: state.mode,
824
846
  cells: state.cells,
825
847
  };