@napster-corp/webmcp-toolkit 1.0.0
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/LICENSE +21 -0
- package/README.md +531 -0
- package/bin/webmcp-toolkit.mjs +81 -0
- package/dist/debug.d.ts +5 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +26 -0
- package/dist/debug.js.map +1 -0
- package/dist/dev-panel.d.ts +22 -0
- package/dist/dev-panel.d.ts.map +1 -0
- package/dist/dev-panel.js +1046 -0
- package/dist/dev-panel.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/model-context.d.ts +13 -0
- package/dist/model-context.d.ts.map +1 -0
- package/dist/model-context.js +28 -0
- package/dist/model-context.js.map +1 -0
- package/dist/resources.d.ts +15 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +179 -0
- package/dist/resources.js.map +1 -0
- package/dist/tiers.d.ts +31 -0
- package/dist/tiers.d.ts.map +1 -0
- package/dist/tiers.js +107 -0
- package/dist/tiers.js.map +1 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/hooks/post-commit +17 -0
- package/package.json +86 -0
- package/skills/add-edge-mcp-dev-panel/SKILL.md +206 -0
- package/skills/plan-capabilities-and-state/SKILL.md +168 -0
- package/skills/setup-edge-mcp/SKILL.md +546 -0
- package/skills/sync-webmcp-tools/SKILL.md +26 -0
- package/src/debug.ts +26 -0
- package/src/dev-panel.ts +1318 -0
- package/src/index.ts +66 -0
- package/src/model-context.ts +31 -0
- package/src/resources.ts +207 -0
- package/src/tiers.ts +132 -0
- package/src/types.ts +177 -0
- package/tools/generate-capabilities.mjs +266 -0
- package/tools/install-hook.mjs +81 -0
- package/tools/runners/anthropic.mjs +75 -0
- package/tools/runners/copilot.mjs +63 -0
package/src/dev-panel.ts
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
// WebMCP Toolkit Dev Panel — opt-in, in-browser UI for testing tools by hand.
|
|
2
|
+
//
|
|
3
|
+
// Self-contained: no framework, no external CSS. It reads the STANDARD surface
|
|
4
|
+
// (`document.modelContext` — tools via getTools()/executeTool(), live state via
|
|
5
|
+
// the toolkit's resource extension), so it inspects exactly what a WebMCP agent
|
|
6
|
+
// sees. Renders into a Shadow DOM so it is fully isolated from the host app.
|
|
7
|
+
//
|
|
8
|
+
// Mount with `installDevPanel()` from the app's entry point, behind a dev-mode
|
|
9
|
+
// guard. The default keyboard shortcut (Cmd+Shift+E on Mac, Ctrl+Shift+E
|
|
10
|
+
// elsewhere) toggles visibility.
|
|
11
|
+
|
|
12
|
+
import { getModelContextWithResources } from './model-context.js';
|
|
13
|
+
import { getTier } from './tiers.js';
|
|
14
|
+
import type {
|
|
15
|
+
ModelContextWithResources,
|
|
16
|
+
ResourceUpdate,
|
|
17
|
+
SideEffect,
|
|
18
|
+
ToolInfo,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
// ───────── Standard-surface adapter ───────────────────────────────────────
|
|
22
|
+
// A small read/invoke layer over `document.modelContext`, so the rest of the
|
|
23
|
+
// panel works in the same shape it did over the old EdgeMcp instance.
|
|
24
|
+
|
|
25
|
+
const EMPTY_OBJECT_SCHEMA: Record<string, unknown> = { type: 'object', properties: {} };
|
|
26
|
+
|
|
27
|
+
/** A tool as the panel renders it: standard info + the toolkit's recorded tier. */
|
|
28
|
+
interface ToolView {
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
inputSchema: Record<string, unknown>;
|
|
32
|
+
tier: SideEffect;
|
|
33
|
+
idempotent: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function listToolViews(mc: ModelContextWithResources): Promise<ToolView[]> {
|
|
37
|
+
const tools = await mc.getTools();
|
|
38
|
+
return tools.map((t: ToolInfo) => {
|
|
39
|
+
// Tier can't ride on the standard tool (getTools() drops annotations), so
|
|
40
|
+
// we read it from the toolkit-side registry. Tools registered with plain
|
|
41
|
+
// standard registerTool (no tier recorded) default to 'reversible'.
|
|
42
|
+
const tier = getTier(t.name);
|
|
43
|
+
return {
|
|
44
|
+
name: t.name,
|
|
45
|
+
description: t.description ?? '',
|
|
46
|
+
inputSchema: (t.inputSchema as Record<string, unknown>) ?? EMPTY_OBJECT_SCHEMA,
|
|
47
|
+
tier: tier?.tier ?? 'reversible',
|
|
48
|
+
idempotent: tier?.idempotent ?? false,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface InvokeOutcome {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
output?: string | null;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function invokeTool(
|
|
60
|
+
mc: ModelContextWithResources,
|
|
61
|
+
name: string,
|
|
62
|
+
args: Record<string, unknown>,
|
|
63
|
+
): Promise<InvokeOutcome> {
|
|
64
|
+
try {
|
|
65
|
+
const tools = await mc.getTools();
|
|
66
|
+
const info = tools.find((t) => t.name === name);
|
|
67
|
+
if (!info) return { ok: false, error: `Unknown tool: ${name}` };
|
|
68
|
+
const output = await mc.executeTool(info, JSON.stringify(args));
|
|
69
|
+
return { ok: true, output };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ───────── Public API ─────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface DevPanelOptions {
|
|
78
|
+
/** Keyboard shortcut to toggle the panel. Default: 'Cmd+Shift+E'. */
|
|
79
|
+
shortcut?: string;
|
|
80
|
+
/** Open the panel as soon as it mounts. Default: false. */
|
|
81
|
+
startOpen?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Mount the dev panel against the standard `document.modelContext`.
|
|
86
|
+
*
|
|
87
|
+
* No argument is needed — the panel reads the standard surface that the
|
|
88
|
+
* toolkit's polyfill installs on import. If called more than once in the same
|
|
89
|
+
* runtime, the previous panel is removed first (so HMR re-runs cleanly).
|
|
90
|
+
*
|
|
91
|
+
* Outside the browser, or if no model context is present, this returns a no-op
|
|
92
|
+
* uninstaller — the panel needs `window`/`document`/`localStorage` and a
|
|
93
|
+
* `document.modelContext` to inspect.
|
|
94
|
+
*
|
|
95
|
+
* Returns an `uninstall` function that removes the panel and tears down its
|
|
96
|
+
* subscriptions.
|
|
97
|
+
*/
|
|
98
|
+
export function installDevPanel(options: DevPanelOptions = {}): () => void {
|
|
99
|
+
// SSR guard: the panel touches `document`, `localStorage`, and keyboard
|
|
100
|
+
// events. None of those exist outside the browser, so do nothing.
|
|
101
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
102
|
+
return () => {
|
|
103
|
+
/* no-op uninstall */
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const mc = getModelContextWithResources();
|
|
107
|
+
if (!mc) {
|
|
108
|
+
// eslint-disable-next-line no-console
|
|
109
|
+
console.warn(
|
|
110
|
+
'[webmcp-toolkit dev panel] no document.modelContext found — is the toolkit imported?',
|
|
111
|
+
);
|
|
112
|
+
return () => {
|
|
113
|
+
/* no-op uninstall */
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (active) active.uninstall();
|
|
117
|
+
active = mountPanel(mc, options);
|
|
118
|
+
return active.uninstall;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ───────── Module state (HMR singleton) ──────────────────────────────────
|
|
122
|
+
|
|
123
|
+
let active: { uninstall: () => void } | null = null;
|
|
124
|
+
|
|
125
|
+
// ───────── Constants ─────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const STORAGE_PREFIX = 'webmcp-toolkit-dev-panel:args:';
|
|
128
|
+
const MAX_LOG_ENTRIES = 200;
|
|
129
|
+
const DEFAULT_SHORTCUT = 'Cmd+Shift+E';
|
|
130
|
+
const TIER_ORDER: SideEffect[] = ['read', 'reversible', 'irreversible'];
|
|
131
|
+
|
|
132
|
+
// ───────── Types ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
type TabName = 'state' | 'capabilities' | 'log';
|
|
135
|
+
|
|
136
|
+
interface LogEntry {
|
|
137
|
+
kind: 'call' | 'result' | 'state';
|
|
138
|
+
name: string;
|
|
139
|
+
payload: unknown;
|
|
140
|
+
ok?: boolean;
|
|
141
|
+
ts: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface TabHandle {
|
|
145
|
+
element: HTMLElement;
|
|
146
|
+
onMount?: () => void;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ───────── Mount ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function mountPanel(mc: ModelContextWithResources, opts: DevPanelOptions) {
|
|
152
|
+
// Host element lives at the very top of the z-stack so it floats above the
|
|
153
|
+
// app. Fixed at the viewport's bottom-right corner — the universal location
|
|
154
|
+
// for in-page dev tools — with a 16px inset.
|
|
155
|
+
const host = document.createElement('div');
|
|
156
|
+
host.setAttribute('data-edge-mcp-dev-panel', '');
|
|
157
|
+
host.style.cssText =
|
|
158
|
+
'position:fixed;bottom:16px;right:16px;z-index:2147483647;width:0;height:0;pointer-events:none;';
|
|
159
|
+
|
|
160
|
+
// Shadow DOM isolates the panel's styles from the host page.
|
|
161
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
162
|
+
const style = document.createElement('style');
|
|
163
|
+
style.textContent = STYLES;
|
|
164
|
+
shadow.appendChild(style);
|
|
165
|
+
|
|
166
|
+
const panel = h('div', { class: 'panel' });
|
|
167
|
+
shadow.appendChild(panel);
|
|
168
|
+
|
|
169
|
+
// Visible affordance for opening the panel when closed. Without this,
|
|
170
|
+
// forgetting the keyboard shortcut leaves the panel discoverable only via
|
|
171
|
+
// DevTools — bad UX. The toggle hides itself when the panel is open.
|
|
172
|
+
const toggle = h(
|
|
173
|
+
'button',
|
|
174
|
+
{
|
|
175
|
+
class: 'toggle',
|
|
176
|
+
type: 'button',
|
|
177
|
+
title: 'Edge MCP dev panel — toggle',
|
|
178
|
+
'aria-label': 'Toggle Edge MCP dev panel',
|
|
179
|
+
},
|
|
180
|
+
'E',
|
|
181
|
+
);
|
|
182
|
+
shadow.appendChild(toggle);
|
|
183
|
+
|
|
184
|
+
// ---- Log + listeners -------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const log: LogEntry[] = [];
|
|
187
|
+
const logListeners = new Set<(entry: LogEntry) => void>();
|
|
188
|
+
|
|
189
|
+
function addLog(entry: LogEntry): void {
|
|
190
|
+
log.unshift(entry);
|
|
191
|
+
if (log.length > MAX_LOG_ENTRIES) log.length = MAX_LOG_ENTRIES;
|
|
192
|
+
for (const fn of logListeners) fn(entry);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Tab content (built once, swapped in/out) -----------------------
|
|
196
|
+
|
|
197
|
+
const stateTab = buildStateTab(mc);
|
|
198
|
+
const capsTab = buildCapabilitiesTab(mc, addLog);
|
|
199
|
+
const logTab = buildLogTab(log, logListeners);
|
|
200
|
+
|
|
201
|
+
// ---- Header + tab bar ------------------------------------------------
|
|
202
|
+
|
|
203
|
+
let activeTab: TabName = 'capabilities';
|
|
204
|
+
const tabBar = h('div', { class: 'tabs' });
|
|
205
|
+
const tabContent = h('div', { class: 'content' });
|
|
206
|
+
|
|
207
|
+
function showTab(tab: TabName): void {
|
|
208
|
+
activeTab = tab;
|
|
209
|
+
Array.from(tabBar.querySelectorAll('.tab')).forEach((btn) => {
|
|
210
|
+
btn.classList.toggle('active', btn.getAttribute('data-tab') === tab);
|
|
211
|
+
});
|
|
212
|
+
tabContent.innerHTML = '';
|
|
213
|
+
const handle = tab === 'state' ? stateTab : tab === 'capabilities' ? capsTab : logTab;
|
|
214
|
+
tabContent.appendChild(handle.element);
|
|
215
|
+
handle.onMount?.();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tabDefs: Array<{ name: TabName; label: string }> = [
|
|
219
|
+
{ name: 'capabilities', label: 'Tools' },
|
|
220
|
+
{ name: 'state', label: 'State' },
|
|
221
|
+
{ name: 'log', label: 'Log' },
|
|
222
|
+
];
|
|
223
|
+
for (const def of tabDefs) {
|
|
224
|
+
const btn = h(
|
|
225
|
+
'button',
|
|
226
|
+
{ class: 'tab', 'data-tab': def.name, type: 'button' },
|
|
227
|
+
def.label,
|
|
228
|
+
);
|
|
229
|
+
btn.addEventListener('click', () => showTab(def.name));
|
|
230
|
+
tabBar.appendChild(btn);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const closeBtn = h('button', { class: 'close', type: 'button', title: 'Hide' }, '×');
|
|
234
|
+
closeBtn.addEventListener('click', () => setOpen(false));
|
|
235
|
+
|
|
236
|
+
const header = h(
|
|
237
|
+
'div',
|
|
238
|
+
{ class: 'header' },
|
|
239
|
+
h('div', { class: 'title' }, 'WebMCP'),
|
|
240
|
+
tabBar,
|
|
241
|
+
closeBtn,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
panel.appendChild(header);
|
|
245
|
+
panel.appendChild(tabContent);
|
|
246
|
+
|
|
247
|
+
// ---- Visibility / shortcut ------------------------------------------
|
|
248
|
+
|
|
249
|
+
let isOpen = !!opts.startOpen;
|
|
250
|
+
|
|
251
|
+
function setOpen(open: boolean): void {
|
|
252
|
+
isOpen = open;
|
|
253
|
+
panel.classList.toggle('open', open);
|
|
254
|
+
toggle.classList.toggle('hidden', open);
|
|
255
|
+
// Re-render the active tab on open so capabilities/state registered
|
|
256
|
+
// since the panel was last shown surface immediately. Renders are
|
|
257
|
+
// idempotent (each tab's onMount no-ops when nothing has changed) so
|
|
258
|
+
// existing form state isn't blown away.
|
|
259
|
+
if (open) {
|
|
260
|
+
const handle =
|
|
261
|
+
activeTab === 'state' ? stateTab : activeTab === 'capabilities' ? capsTab : logTab;
|
|
262
|
+
handle.onMount?.();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
toggle.addEventListener('click', () => setOpen(!isOpen));
|
|
267
|
+
|
|
268
|
+
setOpen(isOpen);
|
|
269
|
+
showTab(activeTab);
|
|
270
|
+
|
|
271
|
+
const shortcut = parseShortcut(opts.shortcut ?? DEFAULT_SHORTCUT);
|
|
272
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
273
|
+
if (matchShortcut(e, shortcut)) {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
setOpen(!isOpen);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
document.addEventListener('keydown', onKey);
|
|
279
|
+
|
|
280
|
+
// ---- Live state updates → log + state tab in place ------------------
|
|
281
|
+
// Listen to the toolkit's `resourceupdated` event on document.modelContext
|
|
282
|
+
// (mirrors MCP's notifications/resources/updated). Payload is { uri, value }.
|
|
283
|
+
|
|
284
|
+
const onResourceUpdate = (evt: Event): void => {
|
|
285
|
+
const detail = (evt as CustomEvent<ResourceUpdate>).detail;
|
|
286
|
+
if (!detail) return;
|
|
287
|
+
addLog({ kind: 'state', name: detail.uri, payload: detail.value, ts: Date.now() });
|
|
288
|
+
// Only refresh the state tab when it's actually visible. When the user
|
|
289
|
+
// next switches to it, showTab() calls onMount which re-reads resources,
|
|
290
|
+
// so they see the current state then.
|
|
291
|
+
if (activeTab === 'state') {
|
|
292
|
+
stateTab.onMount?.();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
mc.addEventListener('resourceupdated', onResourceUpdate);
|
|
296
|
+
|
|
297
|
+
// ---- Mount + report --------------------------------------------------
|
|
298
|
+
|
|
299
|
+
document.body.appendChild(host);
|
|
300
|
+
// eslint-disable-next-line no-console
|
|
301
|
+
console.info(
|
|
302
|
+
`[webmcp-toolkit dev panel] mounted (toggle: ${opts.shortcut ?? DEFAULT_SHORTCUT})`,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
uninstall: () => {
|
|
307
|
+
document.removeEventListener('keydown', onKey);
|
|
308
|
+
mc.removeEventListener('resourceupdated', onResourceUpdate);
|
|
309
|
+
logListeners.clear();
|
|
310
|
+
host.remove();
|
|
311
|
+
active = null;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ───────── State tab ─────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function buildStateTab(mc: ModelContextWithResources): TabHandle {
|
|
319
|
+
const container = h('div', { class: 'tab-pane' });
|
|
320
|
+
const empty = h(
|
|
321
|
+
'div',
|
|
322
|
+
{ class: 'empty' },
|
|
323
|
+
'No live-state resources registered. ',
|
|
324
|
+
h(
|
|
325
|
+
'span',
|
|
326
|
+
{ class: 'subtle' },
|
|
327
|
+
'Resources are reserved for out-of-band state — many apps need none.',
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// Per-resource rows, indexed by uri so we can update in place.
|
|
332
|
+
const rows = new Map<string, { row: HTMLElement; valueEl: HTMLElement; tsEl: HTMLElement }>();
|
|
333
|
+
|
|
334
|
+
function readAll(): Promise<Array<{ uri: string; name: string; value: unknown }>> {
|
|
335
|
+
// getResources() is metadata only (mirrors resources/list); read each value
|
|
336
|
+
// in parallel (mirrors resources/read). A failing read returns undefined.
|
|
337
|
+
const resources = mc.getResources();
|
|
338
|
+
return Promise.all(
|
|
339
|
+
resources.map(async (r) => ({ uri: r.uri, name: r.name, value: await mc.readResource(r.uri) })),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function render(): void {
|
|
344
|
+
container.innerHTML = '';
|
|
345
|
+
rows.clear();
|
|
346
|
+
void readAll().then((entries) => {
|
|
347
|
+
if (entries.length === 0) {
|
|
348
|
+
container.appendChild(empty);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
for (const e of entries.sort((a, b) => a.uri.localeCompare(b.uri))) {
|
|
352
|
+
appendResourceRow(e.uri, e.name, e.value);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function appendResourceRow(uri: string, name: string, value: unknown): void {
|
|
358
|
+
const valueEl = h('pre', { class: 'json' }, formatJson(value));
|
|
359
|
+
const tsEl = h('span', { class: 'ts' }, '(no push yet)');
|
|
360
|
+
const refresh = h('button', { class: 'mini', type: 'button' }, 'Refresh');
|
|
361
|
+
refresh.addEventListener('click', () => {
|
|
362
|
+
void mc.readResource(uri).then((v) => {
|
|
363
|
+
valueEl.textContent = formatJson(v);
|
|
364
|
+
tsEl.textContent = `refreshed ${formatTime(Date.now())}`;
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
const row = h(
|
|
368
|
+
'section',
|
|
369
|
+
{ class: 'card' },
|
|
370
|
+
h(
|
|
371
|
+
'div',
|
|
372
|
+
{ class: 'card-head' },
|
|
373
|
+
h(
|
|
374
|
+
'div',
|
|
375
|
+
{ class: 'card-name' },
|
|
376
|
+
name,
|
|
377
|
+
h('span', { class: 'ts mono' }, ` ${uri}`),
|
|
378
|
+
),
|
|
379
|
+
h('div', { class: 'card-meta' }, tsEl, refresh),
|
|
380
|
+
),
|
|
381
|
+
valueEl,
|
|
382
|
+
);
|
|
383
|
+
container.appendChild(row);
|
|
384
|
+
rows.set(uri, { row, valueEl, tsEl });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// External hook — called whenever a resource push fires.
|
|
388
|
+
function refresh(): void {
|
|
389
|
+
void readAll().then((entries) => {
|
|
390
|
+
for (const e of entries) {
|
|
391
|
+
const existing = rows.get(e.uri);
|
|
392
|
+
if (existing) {
|
|
393
|
+
existing.valueEl.textContent = formatJson(e.value);
|
|
394
|
+
existing.tsEl.textContent = `updated ${formatTime(Date.now())}`;
|
|
395
|
+
} else {
|
|
396
|
+
// First time seeing this resource.
|
|
397
|
+
if (container.contains(empty)) container.removeChild(empty);
|
|
398
|
+
appendResourceRow(e.uri, e.name, e.value);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Re-render whenever the tab is shown (catches newly registered resources).
|
|
405
|
+
return {
|
|
406
|
+
element: container,
|
|
407
|
+
onMount: () => {
|
|
408
|
+
if (rows.size === 0) render();
|
|
409
|
+
else refresh();
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ───────── Capabilities tab ──────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
function buildCapabilitiesTab(
|
|
417
|
+
mc: ModelContextWithResources,
|
|
418
|
+
addLog: (entry: LogEntry) => void,
|
|
419
|
+
): TabHandle {
|
|
420
|
+
const container = h('div', { class: 'tab-pane' });
|
|
421
|
+
// Tracks which tool set was last rendered. Re-renders are skipped when the
|
|
422
|
+
// set hasn't changed, so user input in tool forms isn't blown away every
|
|
423
|
+
// time the tab is shown or the panel is reopened.
|
|
424
|
+
let lastRenderedSignature: string | null = null;
|
|
425
|
+
|
|
426
|
+
function render(): void {
|
|
427
|
+
void listToolViews(mc).then((tools) => {
|
|
428
|
+
// Include enough metadata that a change to a description, tier, flag, or
|
|
429
|
+
// schema triggers a re-render — without this the panel would show stale
|
|
430
|
+
// data after the customer tweaked their registration.
|
|
431
|
+
const signature = tools
|
|
432
|
+
.map(
|
|
433
|
+
(t) =>
|
|
434
|
+
[
|
|
435
|
+
t.name,
|
|
436
|
+
t.description,
|
|
437
|
+
t.tier,
|
|
438
|
+
t.idempotent ? '1' : '0',
|
|
439
|
+
JSON.stringify(t.inputSchema),
|
|
440
|
+
].join('\x1f'),
|
|
441
|
+
)
|
|
442
|
+
.sort()
|
|
443
|
+
.join('\n');
|
|
444
|
+
if (signature === lastRenderedSignature) return;
|
|
445
|
+
lastRenderedSignature = signature;
|
|
446
|
+
|
|
447
|
+
container.innerHTML = '';
|
|
448
|
+
if (tools.length === 0) {
|
|
449
|
+
container.appendChild(
|
|
450
|
+
h(
|
|
451
|
+
'div',
|
|
452
|
+
{ class: 'empty' },
|
|
453
|
+
'No tools registered yet. ',
|
|
454
|
+
h(
|
|
455
|
+
'span',
|
|
456
|
+
{ class: 'subtle' },
|
|
457
|
+
'Register one (registerTool / registerStatefulTool) and reopen — the list refreshes.',
|
|
458
|
+
),
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Group by tier, in safety order.
|
|
464
|
+
const byTier = new Map<SideEffect, ToolView[]>();
|
|
465
|
+
for (const tier of TIER_ORDER) byTier.set(tier, []);
|
|
466
|
+
for (const tool of tools) byTier.get(tool.tier)!.push(tool);
|
|
467
|
+
for (const tier of TIER_ORDER) {
|
|
468
|
+
const group = byTier.get(tier)!;
|
|
469
|
+
if (group.length === 0) continue;
|
|
470
|
+
container.appendChild(h('div', { class: 'group-head' }, tier));
|
|
471
|
+
group.sort((a, b) => a.name.localeCompare(b.name));
|
|
472
|
+
for (const tool of group) container.appendChild(renderToolCard(tool, mc, addLog));
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
element: container,
|
|
479
|
+
onMount: render,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function renderToolCard(
|
|
484
|
+
tool: ToolView,
|
|
485
|
+
mc: ModelContextWithResources,
|
|
486
|
+
addLog: (entry: LogEntry) => void,
|
|
487
|
+
): HTMLElement {
|
|
488
|
+
const form = buildForm(tool.inputSchema, loadArgs(tool.name));
|
|
489
|
+
|
|
490
|
+
const runBtn = h('button', { class: 'run', type: 'button' }, 'Run');
|
|
491
|
+
const resultEl = h('div', { class: 'result hidden' });
|
|
492
|
+
|
|
493
|
+
runBtn.addEventListener('click', () => {
|
|
494
|
+
const args = form.getValues();
|
|
495
|
+
saveArgs(tool.name, args);
|
|
496
|
+
runBtn.disabled = true;
|
|
497
|
+
runBtn.textContent = 'Running…';
|
|
498
|
+
addLog({ kind: 'call', name: tool.name, payload: args, ts: Date.now() });
|
|
499
|
+
void invokeTool(mc, tool.name, args).then((r) => {
|
|
500
|
+
runBtn.disabled = false;
|
|
501
|
+
runBtn.textContent = 'Run';
|
|
502
|
+
renderResult(resultEl, r, tool.tier);
|
|
503
|
+
addLog({
|
|
504
|
+
kind: 'result',
|
|
505
|
+
name: tool.name,
|
|
506
|
+
payload: r,
|
|
507
|
+
ok: r.ok,
|
|
508
|
+
ts: Date.now(),
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const tierBadge = h('span', { class: `badge tier-${tool.tier}` }, tool.tier);
|
|
514
|
+
const flags: Node[] = [];
|
|
515
|
+
if (tool.idempotent) flags.push(h('span', { class: 'badge flag' }, 'idempotent'));
|
|
516
|
+
|
|
517
|
+
const header = h(
|
|
518
|
+
'div',
|
|
519
|
+
{ class: 'card-head' },
|
|
520
|
+
h('div', { class: 'card-name mono' }, tool.name),
|
|
521
|
+
h('div', { class: 'card-meta' }, tierBadge, ...flags),
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const desc = tool.description ? h('p', { class: 'desc' }, tool.description) : null;
|
|
525
|
+
|
|
526
|
+
const card = h('section', { class: 'card' }, header);
|
|
527
|
+
if (desc) card.appendChild(desc);
|
|
528
|
+
card.appendChild(form.element);
|
|
529
|
+
card.appendChild(h('div', { class: 'actions' }, runBtn));
|
|
530
|
+
card.appendChild(resultEl);
|
|
531
|
+
|
|
532
|
+
return card;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function renderResult(el: HTMLElement, r: InvokeOutcome, tier: SideEffect): void {
|
|
536
|
+
el.innerHTML = '';
|
|
537
|
+
el.classList.remove('hidden', 'ok', 'err');
|
|
538
|
+
if (r.ok) {
|
|
539
|
+
el.classList.add('ok');
|
|
540
|
+
el.appendChild(h('div', { class: 'result-head' }, `ok · ${tier}`));
|
|
541
|
+
// executeTool returns the tool's text output (a string), or null.
|
|
542
|
+
el.appendChild(h('pre', { class: 'json' }, r.output == null ? '(no output)' : r.output));
|
|
543
|
+
} else {
|
|
544
|
+
el.classList.add('err');
|
|
545
|
+
el.appendChild(h('div', { class: 'result-head' }, 'error'));
|
|
546
|
+
el.appendChild(h('pre', { class: 'json' }, r.error ?? 'unknown error'));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ───────── Event log tab ─────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
function buildLogTab(
|
|
553
|
+
log: LogEntry[],
|
|
554
|
+
listeners: Set<(entry: LogEntry) => void>,
|
|
555
|
+
): TabHandle {
|
|
556
|
+
const list = h('div', { class: 'log' });
|
|
557
|
+
const empty = h(
|
|
558
|
+
'div',
|
|
559
|
+
{ class: 'empty' },
|
|
560
|
+
'No activity yet. Run a capability or trigger a state change.',
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
function entryEl(e: LogEntry): HTMLElement {
|
|
564
|
+
const time = h('span', { class: 'log-ts' }, formatTime(e.ts));
|
|
565
|
+
const kind = h(
|
|
566
|
+
'span',
|
|
567
|
+
{ class: `log-kind log-${e.kind}${e.kind === 'result' ? (e.ok ? ' ok' : ' err') : ''}` },
|
|
568
|
+
labelFor(e),
|
|
569
|
+
);
|
|
570
|
+
const name = h('span', { class: 'log-name mono' }, e.name);
|
|
571
|
+
const payload = h(
|
|
572
|
+
'pre',
|
|
573
|
+
{ class: 'log-payload' },
|
|
574
|
+
truncate(formatJson(e.payload), 400),
|
|
575
|
+
);
|
|
576
|
+
return h('div', { class: 'log-entry' }, h('div', { class: 'log-row' }, time, kind, name), payload);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function render(): void {
|
|
580
|
+
list.innerHTML = '';
|
|
581
|
+
if (log.length === 0) {
|
|
582
|
+
list.appendChild(empty);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
for (const e of log) list.appendChild(entryEl(e));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Subscribe to new entries.
|
|
589
|
+
listeners.add((entry) => {
|
|
590
|
+
if (list.contains(empty)) list.removeChild(empty);
|
|
591
|
+
list.insertBefore(entryEl(entry), list.firstChild);
|
|
592
|
+
// Trim DOM the same way we trim the in-memory log.
|
|
593
|
+
while (list.childElementCount > MAX_LOG_ENTRIES) list.lastElementChild?.remove();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
element: list,
|
|
598
|
+
onMount: render,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function labelFor(e: LogEntry): string {
|
|
603
|
+
if (e.kind === 'call') return 'CALL';
|
|
604
|
+
if (e.kind === 'state') return 'STATE';
|
|
605
|
+
return e.ok ? 'OK' : 'ERR';
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ───────── Form rendering (recursive, JSON-Schema-driven) ───────────────
|
|
609
|
+
|
|
610
|
+
interface FormHandle {
|
|
611
|
+
element: HTMLElement;
|
|
612
|
+
getValues: () => Record<string, unknown>;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function buildForm(
|
|
616
|
+
schema: Record<string, unknown>,
|
|
617
|
+
initial: Record<string, unknown>,
|
|
618
|
+
): FormHandle {
|
|
619
|
+
const form = h('div', { class: 'form' });
|
|
620
|
+
const type = getString(schema, 'type');
|
|
621
|
+
|
|
622
|
+
// Top-level must be an object schema; anything else falls back to a raw JSON
|
|
623
|
+
// input over the whole args payload.
|
|
624
|
+
if (type !== 'object') {
|
|
625
|
+
return rawJsonHandle(form, initial);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const props = getObject(schema, 'properties');
|
|
629
|
+
const required = new Set(getStringArray(schema, 'required'));
|
|
630
|
+
|
|
631
|
+
if (!props || Object.keys(props).length === 0) {
|
|
632
|
+
form.appendChild(
|
|
633
|
+
h('div', { class: 'subtle small' }, 'No arguments. Press Run.'),
|
|
634
|
+
);
|
|
635
|
+
return { element: form, getValues: () => ({}) };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const values: Record<string, unknown> = { ...initial };
|
|
639
|
+
|
|
640
|
+
for (const [key, fieldSchema] of Object.entries(props)) {
|
|
641
|
+
const field = buildField(key, fieldSchema as Record<string, unknown>, required.has(key), values[key], (v) => {
|
|
642
|
+
if (v === undefined) delete values[key];
|
|
643
|
+
else values[key] = v;
|
|
644
|
+
});
|
|
645
|
+
form.appendChild(field);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
element: form,
|
|
650
|
+
getValues: () => values,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function buildField(
|
|
655
|
+
key: string,
|
|
656
|
+
schema: Record<string, unknown>,
|
|
657
|
+
required: boolean,
|
|
658
|
+
initial: unknown,
|
|
659
|
+
onChange: (v: unknown) => void,
|
|
660
|
+
): HTMLElement {
|
|
661
|
+
const type = getString(schema, 'type');
|
|
662
|
+
const description = getString(schema, 'description');
|
|
663
|
+
const enumValues = getArray(schema, 'enum');
|
|
664
|
+
|
|
665
|
+
const labelText = key + (required ? ' *' : '');
|
|
666
|
+
const wrap = h('div', { class: 'field' });
|
|
667
|
+
wrap.appendChild(h('label', { class: 'field-label' }, labelText));
|
|
668
|
+
|
|
669
|
+
let control: HTMLElement;
|
|
670
|
+
|
|
671
|
+
if (enumValues && enumValues.length > 0) {
|
|
672
|
+
control = enumControl(enumValues, initial, onChange);
|
|
673
|
+
} else if (type === 'string') {
|
|
674
|
+
control = stringControl(initial, onChange);
|
|
675
|
+
} else if (type === 'integer' || type === 'number') {
|
|
676
|
+
control = numberControl(type === 'integer', initial, onChange);
|
|
677
|
+
} else if (type === 'boolean') {
|
|
678
|
+
control = booleanControl(initial, onChange);
|
|
679
|
+
} else if (type === 'object') {
|
|
680
|
+
control = nestedObjectControl(schema, initial, onChange);
|
|
681
|
+
} else if (type === 'array') {
|
|
682
|
+
control = arrayControl(schema, initial, onChange);
|
|
683
|
+
} else {
|
|
684
|
+
control = jsonControl(initial, onChange);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
wrap.appendChild(control);
|
|
688
|
+
|
|
689
|
+
if (description) {
|
|
690
|
+
wrap.appendChild(h('div', { class: 'hint' }, description));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return wrap;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function stringControl(initial: unknown, onChange: (v: unknown) => void): HTMLElement {
|
|
697
|
+
const input = h('input', { type: 'text', class: 'input' }) as HTMLInputElement;
|
|
698
|
+
if (typeof initial === 'string') input.value = initial;
|
|
699
|
+
input.addEventListener('input', () => {
|
|
700
|
+
onChange(input.value === '' ? undefined : input.value);
|
|
701
|
+
});
|
|
702
|
+
return input;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function numberControl(
|
|
706
|
+
integer: boolean,
|
|
707
|
+
initial: unknown,
|
|
708
|
+
onChange: (v: unknown) => void,
|
|
709
|
+
): HTMLElement {
|
|
710
|
+
const input = h('input', { type: 'number', class: 'input' }) as HTMLInputElement;
|
|
711
|
+
if (integer) input.step = '1';
|
|
712
|
+
if (typeof initial === 'number') input.value = String(initial);
|
|
713
|
+
input.addEventListener('input', () => {
|
|
714
|
+
if (input.value === '') {
|
|
715
|
+
onChange(undefined);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const n = integer ? parseInt(input.value, 10) : parseFloat(input.value);
|
|
719
|
+
onChange(Number.isNaN(n) ? undefined : n);
|
|
720
|
+
});
|
|
721
|
+
return input;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function booleanControl(initial: unknown, onChange: (v: unknown) => void): HTMLElement {
|
|
725
|
+
const input = h('input', { type: 'checkbox', class: 'check' }) as HTMLInputElement;
|
|
726
|
+
input.checked = !!initial;
|
|
727
|
+
// Always emit the current checked state.
|
|
728
|
+
onChange(input.checked);
|
|
729
|
+
input.addEventListener('change', () => onChange(input.checked));
|
|
730
|
+
return h('div', { class: 'check-wrap' }, input);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function enumControl(
|
|
734
|
+
values: unknown[],
|
|
735
|
+
initial: unknown,
|
|
736
|
+
onChange: (v: unknown) => void,
|
|
737
|
+
): HTMLElement {
|
|
738
|
+
const select = h('select', { class: 'input' }) as HTMLSelectElement;
|
|
739
|
+
// Allow "unset" so an optional enum can be cleared.
|
|
740
|
+
const blank = h('option', { value: '' }, '— unset —') as HTMLOptionElement;
|
|
741
|
+
select.appendChild(blank);
|
|
742
|
+
for (const v of values) {
|
|
743
|
+
const opt = h('option', { value: String(v) }, String(v)) as HTMLOptionElement;
|
|
744
|
+
select.appendChild(opt);
|
|
745
|
+
}
|
|
746
|
+
if (initial !== undefined && initial !== null) select.value = String(initial);
|
|
747
|
+
select.addEventListener('change', () => {
|
|
748
|
+
if (select.value === '') onChange(undefined);
|
|
749
|
+
else {
|
|
750
|
+
// Match the original enum value's type where possible.
|
|
751
|
+
const match = values.find((v) => String(v) === select.value);
|
|
752
|
+
onChange(match);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
return select;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function nestedObjectControl(
|
|
759
|
+
schema: Record<string, unknown>,
|
|
760
|
+
initial: unknown,
|
|
761
|
+
onChange: (v: unknown) => void,
|
|
762
|
+
): HTMLElement {
|
|
763
|
+
const inner = buildForm(
|
|
764
|
+
schema,
|
|
765
|
+
initial && typeof initial === 'object' && !Array.isArray(initial)
|
|
766
|
+
? (initial as Record<string, unknown>)
|
|
767
|
+
: {},
|
|
768
|
+
);
|
|
769
|
+
// Surface values out through the parent's onChange whenever an inner field
|
|
770
|
+
// changes — we re-walk the inner state.
|
|
771
|
+
const wrapper = h('div', { class: 'nested' }, inner.element);
|
|
772
|
+
// The inner form already mutates its `values` closure; we need to push it up
|
|
773
|
+
// on each mutation. Hook every input/change at this level.
|
|
774
|
+
wrapper.addEventListener('input', () => onChange(inner.getValues()));
|
|
775
|
+
wrapper.addEventListener('change', () => onChange(inner.getValues()));
|
|
776
|
+
// Initialize.
|
|
777
|
+
onChange(inner.getValues());
|
|
778
|
+
return wrapper;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function arrayControl(
|
|
782
|
+
schema: Record<string, unknown>,
|
|
783
|
+
initial: unknown,
|
|
784
|
+
onChange: (v: unknown) => void,
|
|
785
|
+
): HTMLElement {
|
|
786
|
+
const items = getObject(schema, 'items');
|
|
787
|
+
const itemType = items ? getString(items, 'type') : undefined;
|
|
788
|
+
|
|
789
|
+
// Only render a row UI for primitive item types; anything else falls back
|
|
790
|
+
// to a raw JSON input over the whole array.
|
|
791
|
+
if (!items || !itemType || !['string', 'number', 'integer', 'boolean'].includes(itemType)) {
|
|
792
|
+
return jsonControl(initial, onChange);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const wrap = h('div', { class: 'array' });
|
|
796
|
+
const values: unknown[] = Array.isArray(initial) ? [...initial] : [];
|
|
797
|
+
|
|
798
|
+
function emit(): void {
|
|
799
|
+
onChange(values.length === 0 ? undefined : values);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function appendRow(index: number, value: unknown): void {
|
|
803
|
+
const row = h('div', { class: 'array-row' });
|
|
804
|
+
const field = buildField(
|
|
805
|
+
`[${index}]`,
|
|
806
|
+
items!,
|
|
807
|
+
false,
|
|
808
|
+
value,
|
|
809
|
+
(v) => {
|
|
810
|
+
values[index] = v;
|
|
811
|
+
emit();
|
|
812
|
+
},
|
|
813
|
+
);
|
|
814
|
+
const remove = h('button', { class: 'mini', type: 'button' }, '✕');
|
|
815
|
+
remove.addEventListener('click', () => {
|
|
816
|
+
values.splice(index, 1);
|
|
817
|
+
rebuild();
|
|
818
|
+
});
|
|
819
|
+
row.appendChild(field);
|
|
820
|
+
row.appendChild(remove);
|
|
821
|
+
wrap.insertBefore(row, addBtn);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function rebuild(): void {
|
|
825
|
+
// Clear rows, re-append everything (rebuilds correct indices).
|
|
826
|
+
Array.from(wrap.children).forEach((c) => {
|
|
827
|
+
if (c !== addBtn) wrap.removeChild(c);
|
|
828
|
+
});
|
|
829
|
+
values.forEach((v, i) => appendRow(i, v));
|
|
830
|
+
emit();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const addBtn = h('button', { class: 'mini add', type: 'button' }, '+ add');
|
|
834
|
+
addBtn.addEventListener('click', () => {
|
|
835
|
+
values.push(itemType === 'boolean' ? false : '');
|
|
836
|
+
rebuild();
|
|
837
|
+
});
|
|
838
|
+
wrap.appendChild(addBtn);
|
|
839
|
+
rebuild();
|
|
840
|
+
|
|
841
|
+
return wrap;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function jsonControl(initial: unknown, onChange: (v: unknown) => void): HTMLElement {
|
|
845
|
+
const ta = h('textarea', { class: 'input json-input', rows: '3' }) as HTMLTextAreaElement;
|
|
846
|
+
ta.value = initial === undefined ? '' : safeJson(initial);
|
|
847
|
+
ta.addEventListener('input', () => {
|
|
848
|
+
if (ta.value.trim() === '') {
|
|
849
|
+
onChange(undefined);
|
|
850
|
+
ta.classList.remove('invalid');
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
onChange(JSON.parse(ta.value));
|
|
855
|
+
ta.classList.remove('invalid');
|
|
856
|
+
} catch {
|
|
857
|
+
ta.classList.add('invalid');
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
return h(
|
|
861
|
+
'div',
|
|
862
|
+
{ class: 'json-wrap' },
|
|
863
|
+
ta,
|
|
864
|
+
h('div', { class: 'hint' }, 'JSON — falls back to raw input for this schema fragment.'),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function rawJsonHandle(
|
|
869
|
+
form: HTMLElement,
|
|
870
|
+
initial: Record<string, unknown>,
|
|
871
|
+
): FormHandle {
|
|
872
|
+
// Top-level args are not an object schema — give the developer a single JSON
|
|
873
|
+
// textarea over the whole args payload.
|
|
874
|
+
let value: unknown = initial;
|
|
875
|
+
const ta = h('textarea', { class: 'input json-input', rows: '6' }) as HTMLTextAreaElement;
|
|
876
|
+
ta.value = safeJson(initial);
|
|
877
|
+
ta.addEventListener('input', () => {
|
|
878
|
+
try {
|
|
879
|
+
value = JSON.parse(ta.value);
|
|
880
|
+
ta.classList.remove('invalid');
|
|
881
|
+
} catch {
|
|
882
|
+
ta.classList.add('invalid');
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
form.appendChild(
|
|
886
|
+
h(
|
|
887
|
+
'div',
|
|
888
|
+
{ class: 'subtle small' },
|
|
889
|
+
'Top-level schema is not an object. Provide args as JSON.',
|
|
890
|
+
),
|
|
891
|
+
);
|
|
892
|
+
form.appendChild(ta);
|
|
893
|
+
return {
|
|
894
|
+
element: form,
|
|
895
|
+
getValues: () => (value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// ───────── Storage ───────────────────────────────────────────────────────
|
|
900
|
+
|
|
901
|
+
function loadArgs(name: string): Record<string, unknown> {
|
|
902
|
+
try {
|
|
903
|
+
const raw = localStorage.getItem(STORAGE_PREFIX + name);
|
|
904
|
+
if (!raw) return {};
|
|
905
|
+
const parsed: unknown = JSON.parse(raw);
|
|
906
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
907
|
+
return parsed as Record<string, unknown>;
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
/* corrupt entry — ignore */
|
|
911
|
+
}
|
|
912
|
+
return {};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function saveArgs(name: string, args: unknown): void {
|
|
916
|
+
try {
|
|
917
|
+
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(args));
|
|
918
|
+
} catch {
|
|
919
|
+
/* quota / private mode — ignore */
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ───────── Schema accessors (safe casts over `unknown`) ────────────────
|
|
924
|
+
|
|
925
|
+
function getString(schema: Record<string, unknown>, key: string): string | undefined {
|
|
926
|
+
const v = schema[key];
|
|
927
|
+
return typeof v === 'string' ? v : undefined;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function getArray(schema: Record<string, unknown>, key: string): unknown[] | undefined {
|
|
931
|
+
const v = schema[key];
|
|
932
|
+
return Array.isArray(v) ? v : undefined;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function getStringArray(schema: Record<string, unknown>, key: string): string[] {
|
|
936
|
+
const arr = getArray(schema, key);
|
|
937
|
+
return arr ? arr.filter((x): x is string => typeof x === 'string') : [];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function getObject(
|
|
941
|
+
schema: Record<string, unknown>,
|
|
942
|
+
key: string,
|
|
943
|
+
): Record<string, unknown> | undefined {
|
|
944
|
+
const v = schema[key];
|
|
945
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown>;
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ───────── Misc helpers ──────────────────────────────────────────────────
|
|
950
|
+
|
|
951
|
+
function h<K extends keyof HTMLElementTagNameMap>(
|
|
952
|
+
tag: K,
|
|
953
|
+
attrs?: Record<string, string>,
|
|
954
|
+
...children: Array<Node | string>
|
|
955
|
+
): HTMLElementTagNameMap[K] {
|
|
956
|
+
const el = document.createElement(tag);
|
|
957
|
+
if (attrs) {
|
|
958
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
959
|
+
if (k === 'class') el.className = v;
|
|
960
|
+
else el.setAttribute(k, v);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
for (const c of children) {
|
|
964
|
+
if (c == null) continue;
|
|
965
|
+
el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
966
|
+
}
|
|
967
|
+
return el;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function formatJson(v: unknown): string {
|
|
971
|
+
try {
|
|
972
|
+
return JSON.stringify(v, null, 2);
|
|
973
|
+
} catch {
|
|
974
|
+
return String(v);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function safeJson(v: unknown): string {
|
|
979
|
+
try {
|
|
980
|
+
return JSON.stringify(v, null, 2);
|
|
981
|
+
} catch {
|
|
982
|
+
return '';
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function truncate(s: string, max: number): string {
|
|
987
|
+
if (s.length <= max) return s;
|
|
988
|
+
return s.slice(0, max) + '\n… (truncated)';
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function formatTime(ts: number): string {
|
|
992
|
+
const d = new Date(ts);
|
|
993
|
+
const pad = (n: number, w = 2): string => String(n).padStart(w, '0');
|
|
994
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(
|
|
995
|
+
d.getMilliseconds(),
|
|
996
|
+
3,
|
|
997
|
+
)}`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ───────── Keyboard shortcut ────────────────────────────────────────────
|
|
1001
|
+
|
|
1002
|
+
interface ShortcutSpec {
|
|
1003
|
+
ctrl: boolean;
|
|
1004
|
+
meta: boolean;
|
|
1005
|
+
shift: boolean;
|
|
1006
|
+
alt: boolean;
|
|
1007
|
+
key: string;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function parseShortcut(spec: string): ShortcutSpec {
|
|
1011
|
+
const parts = spec.split('+').map((p) => p.trim());
|
|
1012
|
+
const out: ShortcutSpec = { ctrl: false, meta: false, shift: false, alt: false, key: '' };
|
|
1013
|
+
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|
1014
|
+
for (const p of parts) {
|
|
1015
|
+
const lower = p.toLowerCase();
|
|
1016
|
+
if (lower === 'cmd') {
|
|
1017
|
+
if (isMac) out.meta = true;
|
|
1018
|
+
else out.ctrl = true;
|
|
1019
|
+
} else if (lower === 'ctrl' || lower === 'control') out.ctrl = true;
|
|
1020
|
+
else if (lower === 'shift') out.shift = true;
|
|
1021
|
+
else if (lower === 'alt' || lower === 'option') out.alt = true;
|
|
1022
|
+
else if (lower === 'meta' || lower === 'win') out.meta = true;
|
|
1023
|
+
else out.key = lower;
|
|
1024
|
+
}
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function matchShortcut(e: KeyboardEvent, spec: ShortcutSpec): boolean {
|
|
1029
|
+
return (
|
|
1030
|
+
e.key.toLowerCase() === spec.key &&
|
|
1031
|
+
e.ctrlKey === spec.ctrl &&
|
|
1032
|
+
e.metaKey === spec.meta &&
|
|
1033
|
+
e.shiftKey === spec.shift &&
|
|
1034
|
+
e.altKey === spec.alt
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// ───────── Styles ────────────────────────────────────────────────────────
|
|
1039
|
+
|
|
1040
|
+
const STYLES = /* css */ `
|
|
1041
|
+
:host { all: initial; }
|
|
1042
|
+
* { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
1043
|
+
.mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, monospace; }
|
|
1044
|
+
|
|
1045
|
+
.panel {
|
|
1046
|
+
pointer-events: auto;
|
|
1047
|
+
position: absolute;
|
|
1048
|
+
bottom: 0;
|
|
1049
|
+
right: 0;
|
|
1050
|
+
width: 440px;
|
|
1051
|
+
max-width: calc(100vw - 32px);
|
|
1052
|
+
height: 600px;
|
|
1053
|
+
max-height: 80vh;
|
|
1054
|
+
background: #14161c;
|
|
1055
|
+
color: #e6e8ed;
|
|
1056
|
+
border: 1px solid #2a2f3b;
|
|
1057
|
+
border-radius: 10px;
|
|
1058
|
+
box-shadow: 0 14px 40px rgba(0,0,0,.35);
|
|
1059
|
+
display: none;
|
|
1060
|
+
flex-direction: column;
|
|
1061
|
+
overflow: hidden;
|
|
1062
|
+
font-size: 12.5px;
|
|
1063
|
+
line-height: 1.45;
|
|
1064
|
+
}
|
|
1065
|
+
.panel.open { display: flex; }
|
|
1066
|
+
|
|
1067
|
+
/* Toggle — always visible when the panel is closed, hidden when open. */
|
|
1068
|
+
.toggle {
|
|
1069
|
+
pointer-events: auto;
|
|
1070
|
+
position: absolute;
|
|
1071
|
+
bottom: 0;
|
|
1072
|
+
right: 0;
|
|
1073
|
+
width: 36px;
|
|
1074
|
+
height: 36px;
|
|
1075
|
+
border-radius: 50%;
|
|
1076
|
+
background: #14161c;
|
|
1077
|
+
border: 1px solid #2a2f3b;
|
|
1078
|
+
color: #cfd3dc;
|
|
1079
|
+
cursor: pointer;
|
|
1080
|
+
font-size: 13px;
|
|
1081
|
+
font-weight: 700;
|
|
1082
|
+
letter-spacing: .04em;
|
|
1083
|
+
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
1084
|
+
display: flex;
|
|
1085
|
+
align-items: center;
|
|
1086
|
+
justify-content: center;
|
|
1087
|
+
box-shadow: 0 4px 12px rgba(0,0,0,.25);
|
|
1088
|
+
opacity: .7;
|
|
1089
|
+
transition: opacity .12s ease, transform .12s ease;
|
|
1090
|
+
}
|
|
1091
|
+
.toggle:hover { opacity: 1; transform: scale(1.05); }
|
|
1092
|
+
.toggle.hidden { display: none; }
|
|
1093
|
+
|
|
1094
|
+
/* Header */
|
|
1095
|
+
.header {
|
|
1096
|
+
display: flex;
|
|
1097
|
+
align-items: center;
|
|
1098
|
+
gap: 8px;
|
|
1099
|
+
padding: 8px 10px;
|
|
1100
|
+
border-bottom: 1px solid #232732;
|
|
1101
|
+
background: #181b23;
|
|
1102
|
+
}
|
|
1103
|
+
.title {
|
|
1104
|
+
font-weight: 600;
|
|
1105
|
+
letter-spacing: .02em;
|
|
1106
|
+
font-size: 12px;
|
|
1107
|
+
color: #cfd3dc;
|
|
1108
|
+
}
|
|
1109
|
+
.tabs { display: flex; gap: 2px; margin-left: 6px; flex: 1; }
|
|
1110
|
+
.tab {
|
|
1111
|
+
background: transparent;
|
|
1112
|
+
border: 1px solid transparent;
|
|
1113
|
+
color: #9aa0ac;
|
|
1114
|
+
padding: 4px 9px;
|
|
1115
|
+
font-size: 12px;
|
|
1116
|
+
border-radius: 6px;
|
|
1117
|
+
cursor: pointer;
|
|
1118
|
+
}
|
|
1119
|
+
.tab:hover { color: #cfd3dc; }
|
|
1120
|
+
.tab.active { background: #232732; color: #fff; }
|
|
1121
|
+
.close {
|
|
1122
|
+
background: transparent;
|
|
1123
|
+
border: 0;
|
|
1124
|
+
color: #9aa0ac;
|
|
1125
|
+
font-size: 18px;
|
|
1126
|
+
line-height: 1;
|
|
1127
|
+
padding: 0 4px;
|
|
1128
|
+
cursor: pointer;
|
|
1129
|
+
}
|
|
1130
|
+
.close:hover { color: #fff; }
|
|
1131
|
+
|
|
1132
|
+
/* Content */
|
|
1133
|
+
.content {
|
|
1134
|
+
flex: 1;
|
|
1135
|
+
overflow: auto;
|
|
1136
|
+
padding: 10px;
|
|
1137
|
+
}
|
|
1138
|
+
.tab-pane { display: flex; flex-direction: column; gap: 10px; }
|
|
1139
|
+
.empty {
|
|
1140
|
+
padding: 16px;
|
|
1141
|
+
color: #9aa0ac;
|
|
1142
|
+
text-align: center;
|
|
1143
|
+
border: 1px dashed #2a2f3b;
|
|
1144
|
+
border-radius: 8px;
|
|
1145
|
+
}
|
|
1146
|
+
.subtle { color: #6e7382; }
|
|
1147
|
+
.small { font-size: 11.5px; }
|
|
1148
|
+
.group-head {
|
|
1149
|
+
text-transform: uppercase;
|
|
1150
|
+
letter-spacing: .08em;
|
|
1151
|
+
font-size: 10px;
|
|
1152
|
+
color: #6e7382;
|
|
1153
|
+
padding: 6px 2px 0;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/* Cards (capabilities + state providers) */
|
|
1157
|
+
.card {
|
|
1158
|
+
background: #181b23;
|
|
1159
|
+
border: 1px solid #232732;
|
|
1160
|
+
border-radius: 8px;
|
|
1161
|
+
padding: 10px;
|
|
1162
|
+
display: flex;
|
|
1163
|
+
flex-direction: column;
|
|
1164
|
+
gap: 8px;
|
|
1165
|
+
}
|
|
1166
|
+
.card-head {
|
|
1167
|
+
display: flex;
|
|
1168
|
+
align-items: center;
|
|
1169
|
+
justify-content: space-between;
|
|
1170
|
+
gap: 8px;
|
|
1171
|
+
}
|
|
1172
|
+
.card-name { font-weight: 600; color: #e6e8ed; font-size: 13px; }
|
|
1173
|
+
.card-meta { display: flex; align-items: center; gap: 6px; }
|
|
1174
|
+
.desc { margin: 0; color: #9aa0ac; font-size: 12px; }
|
|
1175
|
+
.ts { color: #6e7382; font-size: 11px; }
|
|
1176
|
+
|
|
1177
|
+
/* Badges */
|
|
1178
|
+
.badge {
|
|
1179
|
+
font-size: 10.5px;
|
|
1180
|
+
padding: 2px 6px;
|
|
1181
|
+
border-radius: 4px;
|
|
1182
|
+
text-transform: uppercase;
|
|
1183
|
+
letter-spacing: .04em;
|
|
1184
|
+
background: #232732;
|
|
1185
|
+
color: #cfd3dc;
|
|
1186
|
+
}
|
|
1187
|
+
.tier-read { background: #1e2e2a; color: #6ee0b8; }
|
|
1188
|
+
.tier-reversible { background: #2e2916; color: #f0c66a; }
|
|
1189
|
+
.tier-irreversible { background: #2e1a1a; color: #f08585; }
|
|
1190
|
+
.flag { background: #1b2535; color: #79a8e6; }
|
|
1191
|
+
|
|
1192
|
+
/* Forms */
|
|
1193
|
+
.form { display: flex; flex-direction: column; gap: 8px; }
|
|
1194
|
+
.field { display: flex; flex-direction: column; gap: 3px; }
|
|
1195
|
+
.field-label { font-size: 11.5px; color: #cfd3dc; }
|
|
1196
|
+
.hint { font-size: 11px; color: #6e7382; }
|
|
1197
|
+
.input {
|
|
1198
|
+
background: #0e1015;
|
|
1199
|
+
color: #e6e8ed;
|
|
1200
|
+
border: 1px solid #2a2f3b;
|
|
1201
|
+
border-radius: 6px;
|
|
1202
|
+
padding: 5px 8px;
|
|
1203
|
+
font-size: 12.5px;
|
|
1204
|
+
outline: none;
|
|
1205
|
+
width: 100%;
|
|
1206
|
+
}
|
|
1207
|
+
.input:focus { border-color: #4d80ff; }
|
|
1208
|
+
.input.invalid { border-color: #f08585; }
|
|
1209
|
+
.json-wrap { display: flex; flex-direction: column; gap: 3px; }
|
|
1210
|
+
.json-input { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, monospace; }
|
|
1211
|
+
.check-wrap { padding-top: 2px; }
|
|
1212
|
+
.check {
|
|
1213
|
+
width: 14px; height: 14px; accent-color: #4d80ff;
|
|
1214
|
+
}
|
|
1215
|
+
.nested {
|
|
1216
|
+
border-left: 2px solid #2a2f3b;
|
|
1217
|
+
padding-left: 8px;
|
|
1218
|
+
}
|
|
1219
|
+
.array { display: flex; flex-direction: column; gap: 4px; }
|
|
1220
|
+
.array-row { display: flex; align-items: flex-end; gap: 6px; }
|
|
1221
|
+
.array-row .field { flex: 1; }
|
|
1222
|
+
.mini {
|
|
1223
|
+
background: #232732;
|
|
1224
|
+
color: #cfd3dc;
|
|
1225
|
+
border: 1px solid #2a2f3b;
|
|
1226
|
+
border-radius: 5px;
|
|
1227
|
+
font-size: 11px;
|
|
1228
|
+
padding: 3px 7px;
|
|
1229
|
+
cursor: pointer;
|
|
1230
|
+
}
|
|
1231
|
+
.mini:hover { background: #2a2f3b; }
|
|
1232
|
+
|
|
1233
|
+
/* Actions */
|
|
1234
|
+
.actions { display: flex; justify-content: flex-end; }
|
|
1235
|
+
.run {
|
|
1236
|
+
background: #4d80ff;
|
|
1237
|
+
color: #fff;
|
|
1238
|
+
border: 0;
|
|
1239
|
+
border-radius: 6px;
|
|
1240
|
+
padding: 5px 14px;
|
|
1241
|
+
font-size: 12px;
|
|
1242
|
+
font-weight: 600;
|
|
1243
|
+
cursor: pointer;
|
|
1244
|
+
}
|
|
1245
|
+
.run:hover { background: #5e8eff; }
|
|
1246
|
+
.run:disabled { background: #34406c; cursor: progress; }
|
|
1247
|
+
|
|
1248
|
+
/* Result */
|
|
1249
|
+
.result {
|
|
1250
|
+
border-top: 1px dashed #232732;
|
|
1251
|
+
padding-top: 6px;
|
|
1252
|
+
display: flex;
|
|
1253
|
+
flex-direction: column;
|
|
1254
|
+
gap: 4px;
|
|
1255
|
+
}
|
|
1256
|
+
.result.hidden { display: none; }
|
|
1257
|
+
.result-head {
|
|
1258
|
+
font-size: 11px;
|
|
1259
|
+
text-transform: uppercase;
|
|
1260
|
+
letter-spacing: .04em;
|
|
1261
|
+
}
|
|
1262
|
+
.result.ok .result-head { color: #6ee0b8; }
|
|
1263
|
+
.result.err .result-head { color: #f08585; }
|
|
1264
|
+
|
|
1265
|
+
/* JSON */
|
|
1266
|
+
.json {
|
|
1267
|
+
margin: 0;
|
|
1268
|
+
padding: 6px 8px;
|
|
1269
|
+
background: #0e1015;
|
|
1270
|
+
border: 1px solid #232732;
|
|
1271
|
+
border-radius: 6px;
|
|
1272
|
+
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Consolas, monospace;
|
|
1273
|
+
font-size: 11.5px;
|
|
1274
|
+
white-space: pre-wrap;
|
|
1275
|
+
word-break: break-word;
|
|
1276
|
+
max-height: 220px;
|
|
1277
|
+
overflow: auto;
|
|
1278
|
+
color: #cfd3dc;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/* Log */
|
|
1282
|
+
.log { display: flex; flex-direction: column; gap: 6px; }
|
|
1283
|
+
.log-entry {
|
|
1284
|
+
background: #181b23;
|
|
1285
|
+
border: 1px solid #232732;
|
|
1286
|
+
border-radius: 6px;
|
|
1287
|
+
padding: 6px 8px;
|
|
1288
|
+
display: flex;
|
|
1289
|
+
flex-direction: column;
|
|
1290
|
+
gap: 4px;
|
|
1291
|
+
}
|
|
1292
|
+
.log-row { display: flex; align-items: center; gap: 6px; }
|
|
1293
|
+
.log-ts { color: #6e7382; font-size: 10.5px; font-family: ui-monospace, monospace; }
|
|
1294
|
+
.log-kind {
|
|
1295
|
+
font-size: 10.5px;
|
|
1296
|
+
text-transform: uppercase;
|
|
1297
|
+
letter-spacing: .04em;
|
|
1298
|
+
padding: 1px 5px;
|
|
1299
|
+
border-radius: 3px;
|
|
1300
|
+
background: #232732;
|
|
1301
|
+
color: #cfd3dc;
|
|
1302
|
+
}
|
|
1303
|
+
.log-call { background: #1b2535; color: #79a8e6; }
|
|
1304
|
+
.log-state { background: #2e2916; color: #f0c66a; }
|
|
1305
|
+
.log-result.ok { background: #1e2e2a; color: #6ee0b8; }
|
|
1306
|
+
.log-result.err { background: #2e1a1a; color: #f08585; }
|
|
1307
|
+
.log-name { font-size: 12px; }
|
|
1308
|
+
.log-payload {
|
|
1309
|
+
margin: 0;
|
|
1310
|
+
font-family: ui-monospace, monospace;
|
|
1311
|
+
font-size: 11px;
|
|
1312
|
+
color: #9aa0ac;
|
|
1313
|
+
white-space: pre-wrap;
|
|
1314
|
+
word-break: break-word;
|
|
1315
|
+
max-height: 120px;
|
|
1316
|
+
overflow: auto;
|
|
1317
|
+
}
|
|
1318
|
+
`;
|