dsh-generative-ui 0.0.0 → 0.0.2

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/cordis.patch.yml +6 -0
  4. package/lib/client.js +18568 -0
  5. package/lib/client.js.map +62 -0
  6. package/lib/index.js +1597 -0
  7. package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
  8. package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
  9. package/lib/types/client/canvas/collect.d.ts +45 -0
  10. package/lib/types/client/canvas/index.d.ts +43 -0
  11. package/lib/types/client/canvas/mount.d.ts +30 -0
  12. package/lib/types/client/canvas/panel-css.d.ts +1 -0
  13. package/lib/types/client/canvas/read.d.ts +12 -0
  14. package/lib/types/client/canvas/subpages.d.ts +20 -0
  15. package/lib/types/client/canvas/useDismissable.d.ts +15 -0
  16. package/lib/types/client/index.d.ts +20 -0
  17. package/lib/types/client/runtime/GenUISurface.d.ts +159 -0
  18. package/lib/types/client/runtime/bindings.d.ts +143 -0
  19. package/lib/types/client/runtime/compiler.d.ts +35 -0
  20. package/lib/types/client/runtime/inline-fence.d.ts +23 -0
  21. package/lib/types/client/runtime/observe.d.ts +30 -0
  22. package/lib/types/client/runtime/register.d.ts +2 -0
  23. package/lib/types/client/runtime/registry.d.ts +7 -0
  24. package/lib/types/client/runtime/report-error.d.ts +17 -0
  25. package/lib/types/client/runtime/segments.d.ts +18 -0
  26. package/lib/types/client/runtime/state.d.ts +18 -0
  27. package/lib/types/client/runtime/uno-config.d.ts +16 -0
  28. package/lib/types/client/runtime/uno.d.ts +50 -0
  29. package/lib/types/client/session.d.ts +26 -0
  30. package/lib/types/contract-assets.d.ts +41 -0
  31. package/lib/types/contract.d.ts +56 -0
  32. package/lib/types/index.d.ts +255 -0
  33. package/lib/types/prompt.d.ts +13 -0
  34. package/lib/types/skill.d.ts +27 -0
  35. package/package.json +135 -9
  36. package/src/client/canvas/CanvasLauncher.tsx +52 -0
  37. package/src/client/canvas/CanvasPanel.tsx +238 -0
  38. package/src/client/canvas/collect.ts +188 -0
  39. package/src/client/canvas/index.ts +255 -0
  40. package/src/client/canvas/mount.ts +91 -0
  41. package/src/client/canvas/panel-css.ts +2 -0
  42. package/src/client/canvas/panel.css +242 -0
  43. package/src/client/canvas/read.ts +55 -0
  44. package/src/client/canvas/subpages.ts +109 -0
  45. package/src/client/canvas/useDismissable.ts +37 -0
  46. package/src/client/index.ts +217 -0
  47. package/src/client/runtime/GenUISurface.tsx +359 -0
  48. package/src/client/runtime/bindings.ts +292 -0
  49. package/src/client/runtime/compiler.ts +80 -0
  50. package/src/client/runtime/inline-fence.ts +222 -0
  51. package/src/client/runtime/observe.ts +65 -0
  52. package/src/client/runtime/register.ts +57 -0
  53. package/src/client/runtime/registry.ts +65 -0
  54. package/src/client/runtime/report-error.ts +79 -0
  55. package/src/client/runtime/segments.ts +116 -0
  56. package/src/client/runtime/state.ts +47 -0
  57. package/src/client/runtime/uno-config.ts +71 -0
  58. package/src/client/runtime/uno.ts +124 -0
  59. package/src/client/session.ts +46 -0
  60. package/src/contract-assets.ts +46 -0
  61. package/src/contract.ts +111 -0
  62. package/src/index.ts +583 -0
  63. package/src/prompt.ts +377 -0
  64. package/src/skill.ts +931 -0
  65. package/types/README.md +34 -0
  66. package/types/ai.d.ts +14 -0
  67. package/types/chat.d.ts +14 -0
  68. package/types/check.ts +39 -0
  69. package/types/exec.d.ts +17 -0
  70. package/types/fs.d.ts +17 -0
  71. package/types/importmap.json +10 -0
  72. package/types/standalone/ai.js +7 -0
  73. package/types/standalone/chat.js +6 -0
  74. package/types/standalone/exec.js +7 -0
  75. package/types/standalone/fs.js +18 -0
  76. package/types/standalone/importmap.json +10 -0
  77. package/types/standalone/state.js +24 -0
  78. package/types/standalone/web.js +7 -0
  79. package/types/state.d.ts +25 -0
  80. package/types/web.d.ts +31 -0
  81. package/index.js +0 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * One mounted GenUIRenderer. Owns the imperative renderer's lifecycle and nothing
3
+ * else — what to render and where it comes from is the caller's business, which is
4
+ * what lets the same surface back both the inline chat card and the canvas view.
5
+ */
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { GenUIRenderer } from "partial-react";
8
+ import { createBrowserTsxCompiler } from "./compiler.ts";
9
+ import { mergeFallbackImports } from "partial-react/import-map";
10
+ import { localImports } from "./bindings.ts";
11
+ import { UI4A_ROOT_CLASS, ensureUnoStyles } from "./uno.ts";
12
+
13
+ export type GenUISurfaceProps = {
14
+ /** Full source when settled; the growing prefix while streaming. */
15
+ code: string;
16
+ /** True while `code` is still a prefix, so partial frames get normalized before compiling. */
17
+ streaming?: boolean;
18
+ /**
19
+ * Keep React state across recompiles. Right for a growing stream, where each frame is
20
+ * the previous one plus more text. Wrong for a whole-file replacement: the renderer
21
+ * decides reuse from the hook signature, so a rewrite that keeps the same hooks — an
22
+ * edited canvas usually does — is silently dropped rather than rendered.
23
+ */
24
+ preserveState?: boolean;
25
+ /** Real compile diagnostics. Transient streaming frames are filtered out — see TRANSIENT below. */
26
+ onError?: (error: Error, phase: "transform" | "compile" | "render") => void;
27
+ /** Fires whenever a frame actually painted. Use it to clear a previously shown error. */
28
+ onRendered?: () => void;
29
+ className?: string;
30
+ };
31
+
32
+ /**
33
+ * A ref that always holds the latest value, updated after commit rather than during render.
34
+ *
35
+ * Assigning `ref.current` in the render body is a side effect React may discard or replay.
36
+ * Every reader here is a renderer callback that fires well after commit, so a plain effect
37
+ * is early enough.
38
+ */
39
+ function useLatest<T>(value: T) {
40
+ const ref = useRef(value);
41
+ useEffect(() => {
42
+ ref.current = value;
43
+ }, [value]);
44
+ return ref;
45
+ }
46
+
47
+ /** The compiler owns a single wasm instance; one per document is both enough and what we can afford. */
48
+ let sharedCompiler: ReturnType<typeof createBrowserTsxCompiler> | null = null;
49
+ /** Paired with `disposeCompiler`: the shared instance must go with the wasm behind it. */
50
+ export const dropSharedCompiler = () => {
51
+ sharedCompiler = null;
52
+ };
53
+
54
+ export const compiler = () => {
55
+ if (sharedCompiler === null) {
56
+ sharedCompiler = createBrowserTsxCompiler();
57
+ }
58
+ return sharedCompiler;
59
+ };
60
+
61
+ /**
62
+ * Mid-stream frames legitimately fail: a prefix that has not reached `export default`
63
+ * yet, or a half-written expression. partial-react treats these as transient and keeps
64
+ * the last good frame, so surfacing them would just make the UI flash errors while the
65
+ * model types. Only a failure that survives settling is the caller's business.
66
+ */
67
+ /** Exported for `test/transient.test.ts`: this decides whether the reader sees an error. */
68
+ export const TRANSIENT = /No default export found|Unexpected (end of|eof)/i;
69
+
70
+ /**
71
+ * A dependency that failed to arrive, not code that is wrong. esm.sh cold-starts and the
72
+ * network drops, and the symptom is identical to a broken component — a blank surface — so
73
+ * it is worth a few retries before anyone concludes the model wrote something wrong.
74
+ */
75
+ /** Exported for `test/transient.test.ts`. */
76
+ export const TRANSIENT_LOAD = /failed to fetch|failed to load|networkerror|load failed/i;
77
+ const MAX_RETRIES = 3;
78
+
79
+ /**
80
+ * Whether a reported error is worth another attempt.
81
+ *
82
+ * Extracted because it is the whole of the decision and none of the React: three conditions,
83
+ * each of which sends the reader somewhere different when it is wrong. Exported for
84
+ * `test/retry.test.ts`.
85
+ */
86
+ /**
87
+ * Whether a mid-stream error is the stream not being finished yet.
88
+ *
89
+ * Both patterns come from the parse stages — `No default export found` is thrown inside
90
+ * `importCompiledComponent` (compile), and an unexpected EOF is the transform rejecting a
91
+ * prefix. A card whose own render throws a message that happens to match is a real error, so
92
+ * the phase is part of the question rather than the message alone.
93
+ */
94
+ export const isUnfinishedFrame = (message: string, phase: string, streaming: boolean) => streaming && phase !== "render" && TRANSIENT.test(message);
95
+
96
+ export const shouldRetry = (message: string, phase: string, streaming: boolean, attempts: number) => phase === "compile" && !streaming && TRANSIENT_LOAD.test(message) && attempts < MAX_RETRIES;
97
+ /**
98
+ * What to do with a frame, given what the surface already holds.
99
+ *
100
+ * `pushCode` APPENDS but a session event carries the whole prefix so far, so the difference
101
+ * between these four answers is the difference between a correct surface and one whose buffer
102
+ * doubles on every frame. Pure, because the decision is, and because a state machine that only
103
+ * runs inside an effect with three refs is one nothing ever checks.
104
+ *
105
+ * - `nothing` the frame adds no text, or a settled frame re-delivers what is already painted
106
+ * - `replace` settled: render the whole thing outright
107
+ * - `append` streaming and the buffer is a prefix of this frame: push only the delta
108
+ * - `restart` the prefix was rewritten (a re-delivered history page, or an edit)
109
+ */
110
+ export type Delivery = { do: "nothing" } | { do: "replace"; code: string } | { do: "append"; delta: string } | { do: "restart"; code: string };
111
+
112
+ export const deliveryFor = (code: string, delivered: string, streaming: boolean): Delivery => {
113
+ // Trailing whitespace only: the SETTLED frame is the streamed one plus the newline that sat
114
+ // in front of the closing fence (`parseUi4aSegments` slices to `closeIndex`, the streaming
115
+ // branch slices to the end of the buffer). Byte-comparing those two says "changed", and the
116
+ // `replace` that follows tears the card down and rebuilds it — every card remounted once, at
117
+ // the very moment the reader started using it, losing scroll position, focus and any state
118
+ // held in a component. Nothing a reader can see is different, so nothing should be delivered.
119
+ if (!streaming) return code.trimEnd() === delivered.trimEnd() ? { do: "nothing" } : { do: "replace", code };
120
+ if (!code.startsWith(delivered)) return { do: "restart", code };
121
+ const delta = code.slice(delivered.length);
122
+ return delta === "" ? { do: "nothing" } : { do: "append", delta };
123
+ };
124
+
125
+ /**
126
+ * Route a `Delivery` to the renderer. Returns whether anything was delivered, which is what tells
127
+ * the caller to advance its `delivered` marker.
128
+ *
129
+ * Split from the effect so the three calls can be constrained: `render` replaces the buffer,
130
+ * `pushCode` appends to it, and `clear({ preserveVisualState: true })` starts over WITHOUT
131
+ * blanking what is on screen. Getting `render` and `pushCode` the wrong way round doubles the
132
+ * buffer on every streamed frame, and the difference is one word inside an effect.
133
+ */
134
+ export const deliver = (renderer: RendererCalls, delivery: Delivery): boolean => {
135
+ if (delivery.do === "nothing") return false;
136
+ if (delivery.do === "replace") renderer.render(delivery.code);
137
+ else if (delivery.do === "append") renderer.pushCode(delivery.delta);
138
+ else {
139
+ // Keep the painted frame so the surface does not blink while it starts over.
140
+ renderer.clear({ preserveVisualState: true });
141
+ renderer.pushCode(delivery.code);
142
+ }
143
+ return true;
144
+ };
145
+
146
+ /**
147
+ * What the card imports, as one string — the key the import-map probe is cached against.
148
+ *
149
+ * Compared by value rather than by set, so re-ordering the same imports re-probes. That is
150
+ * deliberate: the probe is cheap and cached downstream, and a set comparison here would be more
151
+ * code to get wrong than the re-probe costs.
152
+ */
153
+ export const importSignature = (code: string) => [...code.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]).join(" ");
154
+
155
+ /** Only what `deliver` touches — the real renderer has far more. */
156
+ export type RendererCalls = {
157
+ render: (code: string) => void;
158
+ pushCode: (delta: string) => void;
159
+ clear: (options: { preserveVisualState: boolean }) => void;
160
+ };
161
+
162
+ /**
163
+ * What to do about an error the renderer reported.
164
+ *
165
+ * - `ignore` the stream is not finished; the next frame supersedes this
166
+ * - `retry` a dependency failed to arrive, and busting the import URLs is the fix
167
+ * - `report` tell the reader
168
+ *
169
+ * Only a SETTLED surface retries: while streaming, the next frame re-delivers on its own, and a
170
+ * retry there would replace the growing buffer with a stale prefix. Compile phase only — a failed
171
+ * dependency import is reported there (`importCompiledComponent` runs inside the compile `catch`,
172
+ * `partial-react/src/runtime.ts:338`), whereas the same message from the RENDER phase is the
173
+ * card's own `fetch` throwing inside its body, where re-importing changes nothing and costs three
174
+ * retries and 2.4 seconds of blank surface before the reader is told anything.
175
+ */
176
+ export const errorAction = (message: string, phase: string, streaming: boolean, attempts: number): "ignore" | "retry" | "report" => {
177
+ if (isUnfinishedFrame(message, phase, streaming)) return "ignore";
178
+ return shouldRetry(message, phase, streaming, attempts) ? "retry" : "report";
179
+ };
180
+
181
+ /** 0.4s / 0.8s / 1.2s covers an esm.sh cold start; past that the package itself is the problem. */
182
+ const RETRY_BACKOFF_MS = 400;
183
+
184
+ /**
185
+ * What `onError` DOES with the three outcomes, separated from where they come from. The decision
186
+ * was already a pure function; the dispatch was not, and the mutation audit could not constrain
187
+ * it — swapping `ignore` for `retry` survived every test, because the only caller lives inside a
188
+ * `GenUIRenderer.create` callback that needs a DOM to reach.
189
+ *
190
+ * Each branch is one line and each is load-bearing: `ignore` must not touch the counter (a
191
+ * streaming frame is not a failed attempt), `retry` must increment BEFORE scheduling (the delay
192
+ * is a function of the count), and `report` must not increment at all.
193
+ */
194
+ /**
195
+ * What to do when an import probe settles. Three outcomes and every one is a bug if wrong:
196
+ *
197
+ * - **stale** — a later frame's probe won the race. Applying this one reverts the map to an
198
+ * older import set, and the newer frame's packages go missing.
199
+ * - **redeliver** — a settled surface has no next frame. `setImportMap` only stores; it
200
+ * schedules nothing, so without a re-render the card stays blank for good.
201
+ * - **store** — while streaming, the very next frame applies the map. Re-delivering here instead
202
+ * would replace the buffer with whatever prefix was current when the probe fired, truncating
203
+ * the stream mid-flight.
204
+ *
205
+ * The `delivered !== ""` part is not defensive: re-rendering an empty buffer clears the surface.
206
+ */
207
+ export const probeOutcome = (signature: string, current: string, streaming: boolean, delivered: string): "stale" | "redeliver" | "store" => {
208
+ if (signature !== current) return "stale";
209
+ return !streaming && delivered !== "" ? "redeliver" : "store";
210
+ };
211
+
212
+ export const dispatchError = (action: "ignore" | "retry" | "report", effects: { attempts: () => number; setAttempts: (n: number) => void; schedule: (ms: number) => void; report: () => void }) => {
213
+ if (action === "ignore") return;
214
+ if (action === "retry") {
215
+ const next = effects.attempts() + 1;
216
+ effects.setAttempts(next);
217
+ effects.schedule(RETRY_BACKOFF_MS * next);
218
+ return;
219
+ }
220
+ effects.report();
221
+ };
222
+
223
+ /**
224
+ * The same import map with a fresh query on every fetched entry.
225
+ *
226
+ * Only `https://esm.sh/` URLs are touched. Local blob URLs are minted per render and already
227
+ * fresh, and appending a query to a `blob:` URL makes it unresolvable — which would break every
228
+ * card rather than fixing one.
229
+ */
230
+ export const bustFetchedImports = (imports: Record<string, string>, attempt: number): Record<string, string> => Object.fromEntries(Object.entries(imports).map(([key, url]) => [key, url.startsWith("https://esm.sh/") ? `${url}${url.includes("?") ? "&" : "?"}ui4a-retry=${attempt}` : url]));
231
+
232
+ export function GenUISurface({ code, streaming = false, preserveState = true, onError, onRendered, className }: GenUISurfaceProps) {
233
+ const hostRef = useRef<HTMLDivElement>(null);
234
+ // State, not a ref: `create` is async, so readiness must be able to trigger the
235
+ // render effect. With a ref, the first pass sees null and nothing ever re-runs it.
236
+ const [renderer, setRenderer] = useState<GenUIRenderer | null>(null);
237
+ // The renderer outlives any one render and calls back into props, so those are read
238
+ // through refs rather than captured — a new handler identity must not re-attach it.
239
+ const onErrorRef = useLatest(onError);
240
+ const onRenderedRef = useLatest(onRendered);
241
+ const streamingRef = useLatest(streaming);
242
+ // Read once at attach: the renderer takes it as a construction option.
243
+ const preserveStateRef = useRef(preserveState);
244
+ /** Retries spent on the current code. Reset by a successful paint and by every new frame. */
245
+ const retriesRef = useRef(0);
246
+ /**
247
+ * Re-deliver the current code after a dependency failed to fetch.
248
+ *
249
+ * `clear` is not enough on its own, though it is necessary — the renderer skips an unchanged
250
+ * compile result. The part that makes this a real retry is **changing the dependency URL**.
251
+ * Measured 2026-08-23: a second `import()` of a URL that already rejected makes **zero**
252
+ * network requests, because the module registry caches the rejection for the page's lifetime.
253
+ * `mergeFallbackImports` maps each bare specifier to a deterministic `https://esm.sh/<pkg>?…`,
254
+ * so re-rendering imports the exact URL that failed and nothing is re-fetched. Every retry
255
+ * before this was a no-op that burned 0.4/0.8/1.2s and reported the same failure.
256
+ */
257
+ const retryRef = useLatest(() => {
258
+ if (renderer === null || code === "") return;
259
+ const attempt = retriesRef.current;
260
+ void mergeFallbackImports(localImports(), code).then((imports) => {
261
+ // Only the fetched esm.sh entries need busting; the local blob URLs are already fresh.
262
+ renderer.setImportMap({ imports: bustFetchedImports(imports, attempt) });
263
+ renderer.clear({ preserveVisualState: true });
264
+ renderer.render(code);
265
+ });
266
+ });
267
+
268
+ useEffect(() => {
269
+ const host = hostRef.current;
270
+ if (host === null) return;
271
+ let disposed = false;
272
+ let attached: GenUIRenderer | null = null;
273
+ void GenUIRenderer.create(host, {
274
+ compiler: compiler(),
275
+ importmap: { imports: localImports() },
276
+ preserveStateOnUpdate: preserveStateRef.current,
277
+ callbacks: {
278
+ onError: (error, phase) => {
279
+ dispatchError(errorAction(error.message, phase, streamingRef.current, retriesRef.current), {
280
+ attempts: () => retriesRef.current,
281
+ setAttempts: (n) => {
282
+ retriesRef.current = n;
283
+ },
284
+ schedule: (ms) => setTimeout(() => retryRef.current(), ms),
285
+ report: () => onErrorRef.current?.(error, phase),
286
+ });
287
+ },
288
+ onRendered: () => {
289
+ retriesRef.current = 0;
290
+ onRenderedRef.current?.();
291
+ },
292
+ },
293
+ }).then((created) => {
294
+ // A fast unmount can land before `create` settles; detaching there would leak the root.
295
+ if (disposed) return void created.detach();
296
+ attached = created;
297
+ setRenderer(created);
298
+ });
299
+ return () => {
300
+ disposed = true;
301
+ // Detach from the local handle, not from a state updater: React skips the updater
302
+ // for an unmounted fiber whenever the queue is not empty, and a renderer that is
303
+ // never detached keeps its React root and its module URL alive for the tab's life.
304
+ attached?.detach();
305
+ attached = null;
306
+ setRenderer(null);
307
+ };
308
+ // Attach exactly once. The refs above are how later prop values reach the renderer
309
+ // without tearing it down, so they deliberately do not belong in these deps.
310
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- see above
311
+ }, []);
312
+
313
+ // The set of bare specifiers the current code imports. Recomputing the fallback map costs a
314
+ // network probe per package, so it is keyed on this rather than run per streamed frame.
315
+ const importedRef = useRef("");
316
+
317
+ // Last code handed to the renderer. A ref, not state: it only ever feeds the next
318
+ // diff, and re-rendering on it would be a render per streamed frame for nothing.
319
+ const deliveredRef = useRef("");
320
+
321
+ useEffect(() => {
322
+ if (renderer === null) return;
323
+ // Anything the model imports beyond the registered react family (recharts, motion, …) has no
324
+ // entry in the import map, and an unresolvable bare specifier fails the whole module import —
325
+ // the surface just stays blank. `mergeFallbackImports` probes esm.sh and fills those in.
326
+ const signature = importSignature(code);
327
+ if (signature !== importedRef.current) {
328
+ importedRef.current = signature;
329
+ // A later frame's probe can settle first; without this the map reverts to an older import set.
330
+ void mergeFallbackImports(localImports(), code).then((imports) => {
331
+ const settle = probeOutcome(signature, importedRef.current, streamingRef.current, deliveredRef.current);
332
+ if (settle === "stale") return;
333
+ renderer.setImportMap({ imports });
334
+ if (settle === "redeliver") renderer.render(deliveredRef.current);
335
+ });
336
+ }
337
+ retriesRef.current = 0;
338
+ // Generated classes exist only in the code that just arrived, so their CSS is produced here
339
+ // rather than at build time. Not awaited: the sheet is appended to `<head>` and applies to
340
+ // whatever is already mounted, so a card paints unstyled for at most a frame instead of
341
+ // holding up every delivery behind a generator that has to boot on the first call.
342
+ void ensureUnoStyles(code, streaming);
343
+ if (!deliver(renderer, deliveryFor(code, deliveredRef.current, streaming))) return;
344
+ // `deliveredRef` must follow every delivery, or a later streaming frame diffs against a
345
+ // prefix this render already superseded.
346
+ deliveredRef.current = code;
347
+ // The refs this reads (`importedRef`, `deliveredRef`, `streamingRef`) are how the effect
348
+ // carries state between frames; listing them would re-run it on values it just wrote.
349
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
350
+ }, [renderer, code, streaming]);
351
+
352
+ // A query container, so generated code can size itself against the space it was given.
353
+ // The same card lands in a chat column and in a panel the reader drags between 320 and
354
+ // 720px, and the viewport tells it nothing about either — `100vw` is the whole window in
355
+ // both. Without `container-type` here a `@container` rule is inert rather than wrong
356
+ // (measured: the guarded declaration simply never applies), which is the kind of failure
357
+ // that reads as the model writing something bad.
358
+ return <div ref={hostRef} className={[UI4A_ROOT_CLASS, className].filter(Boolean).join(" ")} style={{ containerType: "inline-size" }} />;
359
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The `$dsh/*` capability modules generated code may import.
3
+ *
4
+ * These are real TypeScript that lives in our bundle; generated code reaches them through
5
+ * a per-surface blob shim, because a blob URL cannot carry a query string and the surface
6
+ * identity therefore has to be compiled into the module body.
7
+ *
8
+ * Ported from ui4a-playground, minus what this host cannot back: there is no browser-side
9
+ * filesystem service in dsh (`dsh-fs` is the Node half only) and no client-facing model
10
+ * gateway, so `$dsh/fs` and `$dsh/ai` have no implementation here yet.
11
+ */
12
+ import { moduleUrl, registerModules, registryImports } from "./registry.ts";
13
+ import { AI_STREAM_PATH, EXEC_PATH, FS_PATH, WEB_SEARCH_PATH } from "../../contract-assets.ts";
14
+ import { capabilityModule } from "../../contract.ts";
15
+ import { registerRuntimeModules } from "./register.ts";
16
+ import { usePersistedState } from "./state.ts";
17
+
18
+ /** What the plugin's client half lends to generated code. Registered once, at apply. */
19
+ export type Ui4aHost = {
20
+ /** Sends a prompt into the current session, exactly as the composer would. */
21
+ send: (text: string) => void;
22
+ /** The current session's workspace, which the AI route authorizes against. */
23
+ cwd: () => string | undefined;
24
+ /** The open session, so a write runs under the access mode the composer shows. */
25
+ sessionId: () => string;
26
+ };
27
+
28
+ const INTERNAL = capabilityModule("internal");
29
+ let host: Ui4aHost | null = null;
30
+
31
+ /** Named so the round trip can be tested against the real code rather than a copy of it. */
32
+ export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
33
+ const binary = atob(base64);
34
+ const bytes = new Uint8Array(binary.length);
35
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
36
+ return bytes;
37
+ }
38
+
39
+ export function registerUi4aHost(next: Ui4aHost): () => void {
40
+ host = next;
41
+ return () => {
42
+ if (host === next) host = null;
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The capability surface, one group per `$dsh/<group>` module.
48
+ *
49
+ * A function rather than a constant so the host can be swapped (or torn down) without the
50
+ * already-imported blob modules going stale — they close over `bind`, not over a host.
51
+ */
52
+ export function bind() {
53
+ const chat = {
54
+ /**
55
+ * Drives the next turn from inside a card. The text is what the user would have typed:
56
+ * it lands in the transcript as their message, because a turn nobody can see arriving
57
+ * reads as the app talking to itself.
58
+ */
59
+ sendMessage: (text: string) => {
60
+ if (host === null) throw new Error("[dsh-generative-ui] no host bound");
61
+ host.send(text);
62
+ },
63
+ };
64
+ const ai = {
65
+ /**
66
+ * Streams text from the app's own model, one piece per network chunk.
67
+ *
68
+ * Nothing here holds a credential: the Node half forwards to `ctx.llm`, which owns the
69
+ * provider route and the keys. Reach for it when the *content* is the variable part —
70
+ * the recipe, the five candidate names — and skip it when the data is genuinely fixed.
71
+ */
72
+ streamText: (options: Ui4aStreamOptions | string): AsyncIterable<string> => {
73
+ if (host === null) throw new Error("[dsh-generative-ui] no host bound");
74
+ const workspace = host.cwd();
75
+ if (workspace === undefined) throw new Error("[dsh-generative-ui] $dsh/ai needs a session workspace");
76
+ const request = typeof options === "string" ? { prompt: options } : options;
77
+ return streamFrom(workspace, request, request.signal);
78
+ },
79
+ };
80
+
81
+ const fs = {
82
+ /** The file's text. Throws when it does not exist or the session may not read it. */
83
+ readFile: (path: string) => request<{ content: string }>("GET", path).then((body) => body.content),
84
+ /**
85
+ * Directory entries: the name, whether it is a file or a directory, and a file's size.
86
+ *
87
+ * An array of objects rather than of names, because without `type` a card cannot draw a
88
+ * tree — it would have to probe every entry with a second call to find out whether it
89
+ * can be descended into. The host has all three already; we used to drop two of them.
90
+ */
91
+ readdir: (path: string) => request<{ entries: Ui4aDirEntry[] }>("GET", path, undefined, "list=1").then((body) => body.entries),
92
+ /**
93
+ * The file's bytes, for anything that is not text.
94
+ *
95
+ * `readFile` decodes as UTF-8, so a .mid, a wav or a png read that way comes back with
96
+ * every byte above 0x7f replaced by U+FFFD — corrupt, and silently so. Anything handed to
97
+ * `decodeAudioData`, a MIDI parser or an image decoder has to come through here.
98
+ */
99
+ readBytes: (path: string) => request<{ base64: string }>("GET", path, undefined, "bytes=1").then((body) => decodeBase64(body.base64)),
100
+ /**
101
+ * Writes the file, subject to the session's own access mode.
102
+ *
103
+ * Under `Read Only` this rejects exactly as the model's own `write` would — the fence
104
+ * is the host's, not ours, so what a card may do never diverges from what the composer
105
+ * says the session may do.
106
+ */
107
+ // `Promise<void>`, not the `Promise<undefined>` a bare `.then(() => undefined)` infers:
108
+ // what a caller may do with the result is the contract, and `types/chat.d.ts` says void.
109
+ writeFile: async (path: string, content: string): Promise<void> => {
110
+ await request<{ written: string }>("POST", path, content);
111
+ },
112
+ };
113
+
114
+ const exec = {
115
+ /**
116
+ * Runs one command in the workspace and resolves with its output.
117
+ *
118
+ * A non-zero exit resolves rather than rejects: `git status` failing outside a repo is
119
+ * something a card wants to show, not an outage. Only a failure to run at all rejects.
120
+ * The session's own sandbox mode applies, so this is no wider than the model's own bash.
121
+ */
122
+ bash: (command: string, options?: { signal?: AbortSignal }): Promise<Ui4aExecResult> => {
123
+ if (host === null) throw new Error("[dsh-generative-ui] no host bound");
124
+ const workspace = host.cwd();
125
+ if (workspace === undefined) throw new Error("[dsh-generative-ui] $dsh/exec needs a session workspace");
126
+ return execRequest(workspace, host.sessionId(), command, options?.signal);
127
+ },
128
+ };
129
+
130
+ const web = {
131
+ /**
132
+ * One web search, through whichever provider the host composed.
133
+ *
134
+ * Search only: `ctx.web` also does `fetch`, and this deployment turns that off for its own
135
+ * tools because the local backend can reach private-network addresses. A card wanting a page
136
+ * body should ask the user for it or search for a quotable source instead.
137
+ */
138
+ search: (query: string, options?: { maxResults?: number; signal?: AbortSignal }): Promise<Ui4aSearchResult> => {
139
+ if (host === null) throw new Error("[dsh-generative-ui] no host bound");
140
+ const workspace = host.cwd();
141
+ if (workspace === undefined) throw new Error("[dsh-generative-ui] $dsh/web needs a session workspace");
142
+ return searchRequest(workspace, query, options);
143
+ },
144
+ };
145
+
146
+ // No host behind this one — `localStorage` and React are both already in the page. It exists
147
+ // because the model reaches for it unprompted: five of six habit-tracker runs wrote
148
+ // `import { usePersistedState } from "$dsh/state"` against a module that did not exist, and a
149
+ // reworded denial in the skill did not stop it. An unresolvable specifier takes the whole module
150
+ // with it, so the card renders blank.
151
+ const state = { usePersistedState };
152
+
153
+ return { chat, ai, fs, exec, web, state };
154
+ }
155
+
156
+ /** What one search returns. Mirrors the seam's `WebSearchResult`, which is what the route forwards. */
157
+ export type Ui4aSearchResult = {
158
+ /** A provider-generated answer or summary, when the provider makes one (Exa and DeepSeek do not). */
159
+ content?: string;
160
+ sources: readonly { url: string; title?: string; snippet?: string; publishedAt?: string }[];
161
+ /** True when the seam cut `sources` down to the requested bound. */
162
+ truncated: boolean;
163
+ };
164
+
165
+ async function searchRequest(cwd: string, query: string, options?: { maxResults?: number; signal?: AbortSignal }): Promise<Ui4aSearchResult> {
166
+ const response = await fetch(`${WEB_SEARCH_PATH}?cwd=${encodeURIComponent(cwd)}`, {
167
+ method: "POST",
168
+ headers: { "content-type": "application/json" },
169
+ body: JSON.stringify({ query, ...(options?.maxResults === undefined ? {} : { maxResults: options.maxResults }) }),
170
+ signal: options?.signal,
171
+ });
172
+ const body = (await response.json().catch(() => ({}))) as Ui4aSearchResult & { error?: string };
173
+ if (!response.ok) throw new Error(`[dsh-generative-ui] $dsh/web: ${body.error ?? response.statusText}`);
174
+ return body;
175
+ }
176
+
177
+ /** Talks to the fs route, carrying the workspace and the session whose policy applies. */
178
+ async function request<T>(method: "GET" | "POST", path: string, content?: string, extra?: string): Promise<T> {
179
+ if (host === null) throw new Error("[dsh-generative-ui] no host bound");
180
+ const workspace = host.cwd();
181
+ if (workspace === undefined) throw new Error("[dsh-generative-ui] $dsh/fs needs a session workspace");
182
+ const query = `cwd=${encodeURIComponent(workspace)}&session=${encodeURIComponent(host.sessionId())}&path=${encodeURIComponent(path)}${extra === undefined ? "" : `&${extra}`}`;
183
+ const response = await fetch(`${FS_PATH}?${query}`, content === undefined ? { method } : { method, headers: { "content-type": "application/json" }, body: JSON.stringify({ content }) });
184
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
185
+ // A denial is not an outage. Naming it lets the card say "this session is read-only"
186
+ // rather than "something went wrong".
187
+ if (!response.ok) throw new Error(`[dsh-generative-ui] $dsh/fs ${path}: ${body.error ?? response.statusText}`);
188
+ return body as T;
189
+ }
190
+
191
+ /** What a command left behind. `truncated` means the output was cut, not that it failed. */
192
+ export type Ui4aExecResult = { stdout: string; stderr: string; exitCode: number | null; truncated: { stdout: boolean; stderr: boolean }; timedOut: boolean };
193
+
194
+ /** Talks to the exec route. Separate from `request` because the shape and the failure mode differ. */
195
+ async function execRequest(cwd: string, sessionId: string, command: string, signal?: AbortSignal): Promise<Ui4aExecResult> {
196
+ const query = `cwd=${encodeURIComponent(cwd)}&session=${encodeURIComponent(sessionId)}`;
197
+ // Aborting really kills the command: the route hangs its own controller off `req.on("close")`,
198
+ // so dropping the request is what a polling card needs to not stack runs on a slow one.
199
+ const response = await fetch(`${EXEC_PATH}?${query}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ command }), signal });
200
+ const body = (await response.json().catch(() => ({}))) as Ui4aExecResult & { error?: string };
201
+ if (!response.ok) throw new Error(`[dsh-generative-ui] $dsh/exec: ${body.error ?? response.statusText}`);
202
+ return body;
203
+ }
204
+
205
+ /** One entry of a directory listing. `size` is absent for directories. */
206
+ export type Ui4aDirEntry = { name: string; type?: "file" | "directory"; size?: number };
207
+
208
+ /** One user turn plus an optional system prompt — see the route's note on why not more. */
209
+ export type Ui4aStreamOptions = { prompt: string; system?: string; signal?: AbortSignal };
210
+
211
+ /**
212
+ * Decodes the route's plain-text stream into characters.
213
+ *
214
+ * Character-at-a-time rather than chunk-at-a-time because that is what the consumer wants:
215
+ * generated cards append to a buffer and re-parse it, and a card that grows by whole network
216
+ * chunks reads as stuttering rather than typing.
217
+ */
218
+ async function* streamFrom(cwd: string, request: Ui4aStreamOptions, signal?: AbortSignal): AsyncIterable<string> {
219
+ // Aborting stops the model too, not just the reading: the route drops its own generation when
220
+ // the request closes. A card that regenerates per keystroke needs that, or what the reader
221
+ // ends up seeing is whichever of several in-flight calls happens to finish last.
222
+ const response = await fetch(`${AI_STREAM_PATH}?cwd=${encodeURIComponent(cwd)}`, {
223
+ method: "POST",
224
+ headers: { "content-type": "application/json" },
225
+ body: JSON.stringify(request),
226
+ signal,
227
+ });
228
+ if (!response.ok) throw new Error(`[dsh-generative-ui] $dsh/ai: ${response.status} ${response.statusText}`);
229
+ if (response.body === null) throw new Error("[dsh-generative-ui] $dsh/ai: no response body");
230
+ const reader = response.body.getReader();
231
+ const decoder = new TextDecoder();
232
+ while (true) {
233
+ const { done, value } = await reader.read();
234
+ if (done) break;
235
+ // `stream: true` so a multi-byte character split across chunks is not mangled — the
236
+ // failure mode is a replacement character mid-word in any non-ASCII answer.
237
+ //
238
+ // `yield`, not `yield*`: spreading the string hands the consumer one character at a time,
239
+ // which for a card that re-renders per piece is a setState per character. Measured on 560
240
+ // characters of Chinese: 560 iterations spread, 27 by chunk, identical text either way.
241
+ const text = decoder.decode(value, { stream: true });
242
+ if (text !== "") yield text;
243
+ }
244
+ }
245
+
246
+ /** Blob URLs for every `$dsh/*` module, built once and reused by every surface. */
247
+ let cached: Record<string, string> | null = null;
248
+
249
+ export function bindingImports(): Record<string, string> {
250
+ if (cached !== null) return cached;
251
+ // Registered here rather than in `registerUi4aHost`: what goes in the registry is `bind` itself,
252
+ // which does not depend on the host *value*. Hanging it off host registration meant a page with
253
+ // no host — a preview, a harness, the first frame of a session, any profile without
254
+ // `conversation` — got capability blobs importing an EMPTY `$dsh/internal`. The first mount
255
+ // reported `Unresolvable imports` and every one after it rendered silently blank. Verified: with
256
+ // this moved, a `$dsh/state` card mounts and persists with nothing else registered, and a
257
+ // `$dsh/chat` card lays out correctly and only throws `no host bound` when the button is pressed.
258
+ registerModules({ [INTERNAL]: { bind } });
259
+ const internal = moduleUrl(INTERNAL);
260
+ const imports: Record<string, string> = {};
261
+ const bound = bind();
262
+ // Enumerated from `bind()`, not from a list beside it: a group added to the implementation and
263
+ // missed here would simply have no blob module, and a card importing it renders blank with
264
+ // nothing in the console — the failure mode this project spends the most effort on.
265
+ for (const [group, members] of Object.entries(bound)) {
266
+ const names = Object.keys(members);
267
+ // One `export const` per name: ESM export names must be statically visible.
268
+ const source = [`import { bind } from ${JSON.stringify(internal)};`, `const g = bind().${group};`, ...names.map((name) => `export const ${name} = g.${name};`), "export default g;"].join("\n");
269
+ imports[capabilityModule(group)] = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
270
+ }
271
+ cached = imports;
272
+ return imports;
273
+ }
274
+
275
+ export function releaseBindings(): void {
276
+ for (const url of Object.values(cached ?? {})) URL.revokeObjectURL(url);
277
+ cached = null;
278
+ }
279
+
280
+ /**
281
+ * Every module generated code can import without reaching the network: the shell's React
282
+ * family, plus the `$dsh/*` capabilities.
283
+ *
284
+ * Exported from the plugin's client entry as well, so `bun run smoke` can build the blob
285
+ * modules and parse them. They are synthesized strings that nothing type-checks, and a
286
+ * syntax error in one fails the way an unresolvable import does — the whole module graph
287
+ * dies and the card renders blank with no console error.
288
+ */
289
+ export function localImports(): Record<string, string> {
290
+ registerRuntimeModules();
291
+ return { ...registryImports(), ...bindingImports() };
292
+ }