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,116 @@
1
+ /**
2
+ * Splits assistant prose into markdown and inline-ui4a segments.
3
+ *
4
+ * Ported from ui4a-playground/src/components/chat/segments.ts. We only consume the ui4a
5
+ * side — the host renders the markdown — but the parser must still walk the whole text,
6
+ * because a fence's position depends on everything before it.
7
+ */
8
+ import { FENCE_LANG } from "../../contract.ts";
9
+
10
+ export type Ui4aSegment = { code: string; complete: boolean };
11
+
12
+ /**
13
+ * Opening fence. Both tolerances here were measured; do not tighten them.
14
+ *
15
+ * - **The fence need not start a line.** The prompt says "on its own line", but the model
16
+ * routinely continues it straight after a sentence (`……完整元素周期表。\`\`\`\`ui4a/tsx`).
17
+ * Anchoring with `^` drops that whole reply back to markdown, and hundreds of lines of
18
+ * TSX get pasted into the conversation as prose — far worse than a loose match. So the
19
+ * only requirement is that a backtick does not precede it (which would cut a longer
20
+ * fence in half).
21
+ * - **No newline is required after the language.** Same cause: the model glues the first
22
+ * line of code onto the fence line. `[^\n]*` was there for meta like `title=`, and it
23
+ * swallows that code too — hence `inlineCode` below hands it back.
24
+ */
25
+ const FENCE = new RegExp(String.raw`(?<!\x60)(\x60{3,})${FENCE_LANG.replace("/", "\\/")}([^\n]*)(\n|$)`);
26
+
27
+ /**
28
+ * Where the closing fence is, or `-1`.
29
+ *
30
+ * Try the line-anchored form first (nearly every case) and only then scan for an inline
31
+ * one. Running the permissive regex alone would make the lookbehind re-test at every
32
+ * character — and the closing fence is absent for the whole streaming phase, so that is
33
+ * the most expensive possible scan of the whole body, once per frame (measured on a 25KB
34
+ * body: 1µs → 121µs).
35
+ */
36
+ function findClose(body: string, fence: string): number {
37
+ const anchored = body.indexOf(`\n${fence}`);
38
+ if (anchored >= 0 && /^[^\S\n]*(?:\n|$)/.test(body.slice(anchored + 1 + fence.length)) && body[anchored + 1 + fence.length] !== "`") return anchored + 1;
39
+ for (let at = body.indexOf(fence); at >= 0; at = body.indexOf(fence, at + 1)) {
40
+ if (body[at - 1] === "`" || body[at + fence.length] === "`") continue;
41
+ if (/^[^\S\n]*(?:\n|$)/.test(body.slice(at + fence.length))) return at;
42
+ }
43
+ // A closer SHORTER than the opener. Markdown says this does not close the fence, and the
44
+ // model writes it anyway: 18 of 385 openers in the corpus are closed by a shorter run
45
+ // (`open=6 close=4` nine times), and each one is a card that streams forever because nothing
46
+ // ever ends it. Accepting any standalone run of three-plus rescues 16 and cuts 0 short — the
47
+ // single card where a shorter run precedes the matching one had closed itself twice, so
48
+ // cutting at the first is the same body. Tried last so an exact match always wins.
49
+ const short = /(?:^|\n)[^\S\n]*(`{3,})[^\S\n]*(?:\n|$)/.exec(body);
50
+ return short === undefined || short === null ? -1 : short.index + (short[0].startsWith("\n") ? 1 : 0);
51
+ }
52
+
53
+ /**
54
+ * Tool-call markup the model leaked into its own prose. The reply ends mid-fence with the
55
+ * closing tags glued to the last line of TSX, so the body reaches the compiler with those tags
56
+ * in it and fails to parse — the whole card is lost, not just the closing fence.
57
+ *
58
+ * Two spellings, and the rarer one was found first: `</parameter></invoke>` appeared once in
59
+ * the corpus, while the model's own `</||DSML||parameter>` form accounts for three more and
60
+ * was invisible to a regex written from that single sample. Those full-width bars are U+FF5C,
61
+ * not ASCII `|`. Only stripped at the very end of an unterminated body, where nothing
62
+ * legitimate can follow — which is true of a closed fence too: the model leaks the tags and then
63
+ * still writes the closing fence, and stripping only the unterminated case loses that card outright.
64
+ */
65
+ export const TOOL_CALL_MARKUP = /\n?(?:<\/(?:antml:|||DSML||)?(?:parameter|invoke|tool_calls)>\s*)+$/;
66
+
67
+ /**
68
+ * The prompt asks for four backticks — generated TSX contains triple-backtick strings often
69
+ * enough that a triple-backtick fence would be closed early by its own body. Three or more
70
+ * are accepted anyway: models do not always comply, and one backtick short should not
71
+ * demote the whole block to a plain code listing. The closing fence matches the opening
72
+ * length, so the four-backtick form still tolerates triples inside.
73
+ *
74
+ * An unterminated fence still yields a segment with `complete: false` — that is exactly the
75
+ * frame a streaming reply is in, and rendering it is the entire point.
76
+ */
77
+ const CODE_LINE = /^(import|export|const|function|type|interface|let|\/\/)\b/;
78
+ const FENCE_META = /^[\w-]+=/;
79
+
80
+ export function parseUi4aSegments(text: string): Ui4aSegment[] {
81
+ const segments: Ui4aSegment[] = [];
82
+ let rest = text;
83
+ while (true) {
84
+ const open = FENCE.exec(rest);
85
+ if (open === null) return segments;
86
+ // Leftovers after `ui4a/tsx` on the fence line: normally empty (or meta like `title=`),
87
+ // but the model sometimes puts the first line of code there. If it looks like code,
88
+ // hand it back as the body's first line rather than dropping it.
89
+ const trailing = open[2].trim();
90
+ const bodyStart = open.index + open[0].length;
91
+ // A fence opener the model is *talking about* rather than opening. Measured: 19 of 405
92
+ // openers in the corpus are prose, and 14 of them put the sentence right after the
93
+ // language (`\`\`\`\`ui4a/tsx\`\`\`\` 块,原地渲染成…`). Anything that is not code and not
94
+ // `key=value` meta is one; skipping the whole opener costs 0 of 390 real cards.
95
+ if (trailing !== "" && !CODE_LINE.test(trailing) && !FENCE_META.test(trailing)) {
96
+ rest = rest.slice(bodyStart);
97
+ continue;
98
+ }
99
+ // A wrapper: the model opened a wider fence and then opened the real one inside it, the way
100
+ // this project's own prompt shows the block (`\`\`\`\`\`` around `\`\`\`\`ui4a/tsx`). Taking the
101
+ // outer one gives a body that is the inner fence AS TEXT — which compiles, silently, to a
102
+ // card that renders nothing. Once in 389 corpus openers, and it costs the reader the card.
103
+ if (/^[^\S\n]*`{3,}ui4a\/tsx/.test(rest.slice(bodyStart))) {
104
+ rest = rest.slice(bodyStart);
105
+ continue;
106
+ }
107
+ const inlineCode = CODE_LINE.test(trailing) ? `${trailing}\n` : "";
108
+ const closeIndex = findClose(rest.slice(bodyStart), open[1]);
109
+ if (closeIndex === -1) {
110
+ segments.push({ code: (inlineCode + rest.slice(bodyStart)).replace(TOOL_CALL_MARKUP, ""), complete: false });
111
+ return segments;
112
+ }
113
+ segments.push({ code: (inlineCode + rest.slice(bodyStart, bodyStart + closeIndex)).replace(TOOL_CALL_MARKUP, ""), complete: true });
114
+ rest = rest.slice(bodyStart + closeIndex).replace(new RegExp(String.raw`^${open[1]}[^\n]*\n?`), "");
115
+ }
116
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `$dsh/state` — the one capability the model asks for without being told it exists.
3
+ *
4
+ * Three runs of a habit-tracker prompt each wrote `import { usePersistedState } from "$dsh/state"`
5
+ * against a module that did not exist, which does not degrade: the browser refuses the module and
6
+ * the card renders blank. Rewording the skill to deny it did not help — the prior survives the
7
+ * denial. So the module exists now, with the signature all three runs assumed.
8
+ *
9
+ * Unlike the other capabilities this needs nothing from the host: `localStorage` and React are
10
+ * both already there. That is also why it is worth having — the alternative the skill used to
11
+ * prescribe is fifteen lines of try/catch that every card rewrites and half of them skip.
12
+ */
13
+ import * as React from "react";
14
+
15
+ /** Namespaced so two cards picking the same obvious key ("todos") do not read each other's data. */
16
+ const scope = (key: string) => `dsh-genui:${key}`;
17
+
18
+ function read<T>(key: string, initial: T | (() => T)): T {
19
+ // `initial` may be a lazy initialiser — the idiom `useState` teaches, and what a card writing
20
+ // `usePersistedState(KEY, loadFromSomewhere())` ends up passing by accident either way.
21
+ const fallback = () => (typeof initial === "function" ? (initial as () => T)() : initial);
22
+ try {
23
+ const raw = globalThis.localStorage?.getItem(scope(key));
24
+ return raw === null || raw === undefined ? fallback() : (JSON.parse(raw) as T);
25
+ } catch {
26
+ // Private mode, a full quota, or a value someone else wrote that is not JSON. A tracker that
27
+ // starts empty is worth more than one that throws during render.
28
+ return fallback();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * `useState`, except the value survives a reload — and, more often, survives the remount that
34
+ * every canvas revision and every inline transcript re-render causes.
35
+ */
36
+ export function usePersistedState<T>(key: string, initial: T | (() => T)): [T, React.Dispatch<React.SetStateAction<T>>] {
37
+ const [value, setValue] = React.useState<T>(() => read(key, initial));
38
+ React.useEffect(() => {
39
+ try {
40
+ globalThis.localStorage?.setItem(scope(key), JSON.stringify(value));
41
+ } catch {
42
+ // Quota or private mode. The card keeps working in memory; failing the write must not
43
+ // fail the render.
44
+ }
45
+ }, [key, value]);
46
+ return [value, setValue];
47
+ }
@@ -0,0 +1,71 @@
1
+ import { presetWind4 } from "@unocss/preset-wind4";
2
+ import type { UserConfig } from "@unocss/core";
3
+
4
+ /**
5
+ * Two things the host forces on this config, both non-negotiable:
6
+ *
7
+ * `important` receives a SELECTOR STRING, which is how UnoCSS scopes: every rule comes out
8
+ * `.ui4a-root :is(.gap-4){…}`. The runtime sheet is appended to `<head>` last, so an unscoped
9
+ * `hidden` written by a card would win over the shell's own `hidden` and make part of the app
10
+ * vanish. The playground has that bug on record (a sidebar disappearing); we start scoped.
11
+ *
12
+ * `preflights: { reset: false }` drops presetWind4's global reset — 3.5KB of `*, ::before,
13
+ * ::after { margin: 0; border: 0 solid }` that would land on the HOST's DOM, not just ours.
14
+ * The `theme` layer survives it and is the part we need: `--spacing` and `--radius-*`, which
15
+ * every `gap-*` and `rounded-*` resolves against. Without preflights entirely those rules
16
+ * generate but compute to nothing.
17
+ */
18
+ export const unoConfig = (scope: string): UserConfig => ({
19
+ presets: [presetWind4({ important: scope, preflights: { reset: false } })],
20
+ // The reset above is dropped because it targets `*` and would land on the HOST's DOM. But
21
+ // dropping it leaves form controls carrying the browser's own chrome, and that is not
22
+ // theme-aware: measured on a card in dark mode, two unselected `<button>`s rendered as light
23
+ // grey blocks with black text, because a button with no background class falls back to the UA's
24
+ // `buttonface`. The card looked right in light and broken in dark, which is the failure this
25
+ // whole colour system exists to prevent.
26
+ //
27
+ // So: the same normalisation, scoped to our root.
28
+ //
29
+ // `box-sizing: border-box` is in here for the same reason, and I left it out at first on the
30
+ // theory that cards set their own sizing. They cannot: `w-full` is `width: 100%`, and under the
31
+ // UA's `content-box` that 100% is the content alone, so every `<input className="w-full px-3
32
+ // border">` is padding-plus-border wider than its parent. Measured on wave 2 — every card with a
33
+ // text field overflowed its own edge by 10px at 320, 440 AND 720, which is the tell that it was
34
+ // never a breakpoint problem. The clip in a screenshot is taken at the card width, so the
35
+ // overflowing strip is not cut off, it is absent.
36
+ preflights: [
37
+ {
38
+ // Same class of bug as box-sizing: a card writes `<ul className="grid gap-1.5">`, which is
39
+ // correct — a grid list has no use for UA markers — and dropping the reset put them back, so
40
+ // a column of stray bullets sits OUTSIDE the card border at every width. Not something to
41
+ // ask the model to write `list-none` for on every list.
42
+ getCSS: () => `${scope} *, ${scope} *::before, ${scope} *::after { box-sizing: border-box; }
43
+ ${scope} ul, ${scope} ol { list-style: none; margin: 0; padding: 0; }
44
+ ${scope} button, ${scope} input, ${scope} select, ${scope} textarea {
45
+ background: transparent; color: inherit; font: inherit; border: 0 solid; cursor: pointer;
46
+ }
47
+ ${scope} input, ${scope} select, ${scope} textarea { cursor: auto; }`,
48
+ },
49
+ ],
50
+ theme: {
51
+ colors: {
52
+ // The host's 12 semantic tokens, under names short enough to write in a class.
53
+ // A card can still reach any variable through an arbitrary value: `bg-[var(--dsw-…)]`.
54
+ // NOT `base`: `text-base` is Wind4's body font size, and a colour of that name wins the
55
+ // token, so `<h2 className="text-base">` computed `color: #ffffff` on a white card — present,
56
+ // laid out, invisible, and no probe can see it. 18 corpus cards wrote `text-base`.
57
+ page: "var(--dsw-alias-bg-base)",
58
+ layer: "var(--dsw-alias-bg-layer-1)",
59
+ "layer-2": "var(--dsw-alias-bg-layer-2)",
60
+ line: "var(--dsw-alias-border-l1)",
61
+ "line-2": "var(--dsw-alias-border-l2)",
62
+ label: "var(--dsw-alias-label-primary)",
63
+ muted: "var(--dsw-alias-label-secondary)",
64
+ accent: "var(--dsw-alias-state-business-primary)",
65
+ hover: "var(--dsw-alias-interactive-bg-hover)",
66
+ danger: "var(--dsw-alias-state-error-primary)",
67
+ success: "var(--dsw-alias-state-success-primary)",
68
+ warn: "var(--dsw-alias-state-warn-primary)",
69
+ },
70
+ },
71
+ });
@@ -0,0 +1,124 @@
1
+ import { createGenerator, type UnoGenerator } from "@unocss/core";
2
+ import { unoConfig } from "./uno-config.ts";
3
+
4
+ /**
5
+ * Runtime UnoCSS for generated cards.
6
+ *
7
+ * A build-time pass would scan OUR source, and the classes a card is written with do not exist
8
+ * there — they are typed by the model seconds ago. Responsive is where that shows worst: not one
9
+ * `@container` breakpoint would be generated, so every card would be single-column at any width.
10
+ * The CSS therefore has to be produced in the browser, as the code streams in.
11
+ *
12
+ * Accumulate rather than replace: several cards share one document, and each one's classes must
13
+ * stay in the sheet after another card is added.
14
+ */
15
+ /**
16
+ * The class every generated rule is prefixed with, and the one the surface carries.
17
+ *
18
+ * Named after the contract rather than after this plugin: the same class exists in
19
+ * `ui4a-playground` under the same constant name, so the two runtimes can be diffed line for
20
+ * line. It is also the only marker on the surface node — a second `data-*` hook naming the same
21
+ * thing was removed because nothing read it.
22
+ */
23
+ export const UI4A_ROOT_CLASS = "ui4a-root";
24
+ const PLUGIN_ID = "dsh-generative-ui";
25
+
26
+ let generator: Promise<UnoGenerator> | null = null;
27
+ const tokens = new Set<string>();
28
+ let sheet: HTMLStyleElement | null = null;
29
+ /** Whether the sheet already carries the preflight block. Reset with the sheet, not with the tokens. */
30
+ let preflighted = false;
31
+
32
+ /**
33
+ * Per frame this does the two cheap things only: EXTRACT the class names out of the code
34
+ * (no CSS generated), and generate CSS for the ones not seen before.
35
+ *
36
+ * The expensive spellings, both measured in the playground this is ported from:
37
+ * `uno.generate(code)` regenerates every class in the file each time — 119s of main thread over
38
+ * one streaming canvas; and regenerating the whole accumulated token set on each new class costs
39
+ * more the longer the file gets. Throttling does not help when a single call is what is
40
+ * expensive.
41
+ *
42
+ * Appended rules sort after existing ones, so two same-priority utilities can resolve differently
43
+ * than a single authoritative pass would. Once the stream settles we regenerate the whole set to
44
+ * restore that order.
45
+ */
46
+ export async function ensureUnoStyles(code: string, streaming = false): Promise<void> {
47
+ if (typeof document === "undefined") return;
48
+ const uno = await (generator ??= createGenerator(unoConfig(`.${UI4A_ROOT_CLASS}`)));
49
+ const extracted = await uno.applyExtractors(code);
50
+ const fresh = [...extracted].filter((token) => !tokens.has(token));
51
+ if (fresh.length === 0 && streaming && preflighted) return;
52
+ for (const token of fresh) tokens.add(token);
53
+ sheet ??= createSheet();
54
+ if (streaming) {
55
+ // Preflights FIRST, on the very first streaming frame, and never again. They were reachable
56
+ // only through the settled path, so for the whole of a stream the card rendered without
57
+ // `box-sizing: border-box` and without the button/list reset — measured on a first card in a
58
+ // fresh page: 31 seconds of streaming with `pre: false` and a painting card whose `<input>`
59
+ // computed `content-box`. That is not "unstyled until it settles", it is *wrongly* styled
60
+ // while the reader watches: a `w-full px-3 border` input is padding-plus-border wider than
61
+ // its parent, so the layout is visibly broken and then jumps when the stream ends.
62
+ //
63
+ // They are a fixed block that depends on nothing the model types, so there is no reason for
64
+ // them to wait for anything, and generating them once costs a single call.
65
+ if (!preflighted) {
66
+ preflighted = true;
67
+ const { css } = await uno.generate([], { preflights: true });
68
+ sheet.textContent += splitVendorRules(css);
69
+ }
70
+ if (fresh.length === 0) return;
71
+ const { css } = await uno.generate(fresh, { preflights: false });
72
+ sheet.textContent += splitVendorRules(css);
73
+ return;
74
+ }
75
+ preflighted = true;
76
+ const { css } = await uno.generate(tokens, { preflights: true });
77
+ sheet.textContent = splitVendorRules(css);
78
+ }
79
+
80
+ /**
81
+ * Split a rule whose selector list mixes vendor pseudo-elements into one rule per vendor.
82
+ *
83
+ * UnoCSS merges selectors that share a declaration, so a card styling a slider for both engines
84
+ * gets `…::-moz-range-thumb, …::-webkit-slider-thumb { height: … }` as ONE rule — and Chromium
85
+ * drops the whole rule because it does not recognise the `-moz-` half. Measured: the browser
86
+ * parsed 75 of the 87 rules in a real card's sheet, the slider came out `height: 0px`, and the
87
+ * card shipped three invisible controls. Order does not matter and neither does which vendor is
88
+ * first; one unknown pseudo-element poisons the list.
89
+ *
90
+ * The model is doing the right thing by writing both prefixes, so the fix belongs here.
91
+ */
92
+ export function splitVendorRules(css: string): string {
93
+ return css.replaceAll(/(^|\})\s*([^{}]*::-moz-[^{}]*)\{([^}]*)\}/g, (whole, lead: string, selectors: string, body: string) => {
94
+ const parts = selectors
95
+ .split(",")
96
+ .map((s) => s.trim())
97
+ .filter(Boolean);
98
+ if (parts.length < 2) return whole;
99
+ const moz = parts.filter((s) => s.includes("::-moz-"));
100
+ const rest = parts.filter((s) => !s.includes("::-moz-"));
101
+ if (moz.length === 0 || rest.length === 0) return whole;
102
+ return `${lead}\n${rest.join(",\n")}{${body}}\n${moz.join(",\n")}{${body}}`;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * `data-plugin` is required, not decorative — see `canvas/mount.ts`: the loader claims every
108
+ * unmarked `<style>` for whichever plugin is materializing, and would tear this one out with it.
109
+ */
110
+ function createSheet(): HTMLStyleElement {
111
+ const style = document.createElement("style");
112
+ style.setAttribute("data-plugin", PLUGIN_ID);
113
+ style.setAttribute("data-plugin-css", `${PLUGIN_ID}/uno`);
114
+ return document.head.appendChild(style);
115
+ }
116
+
117
+ /** Drops the sheet and the generator. HMR reloads the module; the old sheet must not survive it. */
118
+ export function disposeUnoStyles(): void {
119
+ sheet?.remove();
120
+ sheet = null;
121
+ generator = null;
122
+ preflighted = false;
123
+ tokens.clear();
124
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Reading the current session's chat nodes.
3
+ *
4
+ * Both consumers — inline fences and canvases — need the same unwrap, and both run it on
5
+ * every frame while a reply streams. Sharing it keeps the guards in one place, and lets
6
+ * the per-node work be cached against a node's identity rather than redone per frame.
7
+ */
8
+ import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
9
+
10
+ export type ChatNodeView = { readonly kind: string; readonly data: unknown; readonly anchorSeq: number };
11
+
12
+ /** The current session's chat nodes, or an empty list when no session is open. */
13
+ export function chatNodes(ctx: ClientContext): readonly ChatNodeView[] {
14
+ const sessionId = ctx.sessions.list.getSnapshot().current;
15
+ if (sessionId === undefined) return [];
16
+ const snapshot = ctx.sessions.binding(sessionId)?.session.getSnapshot();
17
+ if (snapshot === undefined) return [];
18
+ return [...snapshot.chat.nodes.values()] as readonly ChatNodeView[];
19
+ }
20
+
21
+ /**
22
+ * Derives a value per chat node, reusing the previous result when the node has not changed.
23
+ *
24
+ * A sweep runs on every frame of a streaming reply, but only the tail node is actually
25
+ * growing — re-deriving finished nodes means re-scanning the whole transcript dozens of
26
+ * times a second, which grows with session length rather than with what changed.
27
+ *
28
+ * @param key - identity of a node's current content; equal keys must mean equal results.
29
+ * @param derive - the per-node work to memoize.
30
+ */
31
+ export function perNode<T>(key: (node: ChatNodeView) => string, derive: (node: ChatNodeView) => T) {
32
+ let cache = new Map<string, T>();
33
+ return (nodes: readonly ChatNodeView[]): T[] => {
34
+ const next = new Map<string, T>();
35
+ const results: T[] = [];
36
+ for (const node of nodes) {
37
+ const id = key(node);
38
+ const value = cache.get(id) ?? derive(node);
39
+ next.set(id, value);
40
+ results.push(value);
41
+ }
42
+ // Replacing the map drops entries for nodes that left the loaded window.
43
+ cache = next;
44
+ return results;
45
+ };
46
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Asset URLs shared by both halves. Kept apart from index.ts so the browser half
3
+ * can import them without dragging node:fs and createRequire into its bundle.
4
+ */
5
+ export const ASSET_PREFIX = "/dsh-generative-ui/assets";
6
+ export const WASM_PATH = `${ASSET_PREFIX}/tsx_bg.wasm`;
7
+
8
+ /** Reads one canvas file from the session's workspace: `?cwd=<workspace>&id=<canvas>`. */
9
+ export const CANVAS_READ_PATH = "/dsh-generative-ui/canvas";
10
+
11
+ /**
12
+ * Streams one model call for a generated card: POST `{prompt|messages, system?}`.
13
+ *
14
+ * The host owns the credentials and the provider route, so this forwards to `ctx.llm`
15
+ * rather than carrying a key of its own.
16
+ */
17
+ export const AI_STREAM_PATH = "/dsh-generative-ui/ai";
18
+
19
+ /**
20
+ * Filesystem access for a generated card: `?cwd=<workspace>&path=<path>`.
21
+ *
22
+ * GET reads (or lists, with `?list=1`), POST writes. Both go through the host's `ctx.fs`
23
+ * and carry the session's own sandbox policy, so what a card may do is exactly what the
24
+ * session may do — `read-only` denies the write at the fence rather than here.
25
+ */
26
+ export const FS_PATH = "/dsh-generative-ui/fs";
27
+
28
+ /**
29
+ * Runs one command for a generated card: `?cwd=<workspace>&session=<id>`, POST `{command}`.
30
+ *
31
+ * Under the session's own sandbox policy, exactly as `FS_PATH` is — a read-only session gets
32
+ * a read-only shell rather than a different fence. Foreground only: a card that wants a
33
+ * long-running process wants a different product.
34
+ */
35
+ export const EXEC_PATH = "/dsh-generative-ui/exec";
36
+
37
+ /**
38
+ * One web search for a generated card: `?cwd=<workspace>`, POST `{query, maxResults?}`.
39
+ *
40
+ * Search only. `ctx.web` also exposes `fetch`, and this deliberately does not forward it: the
41
+ * deployment's own `tool-web` is configured `fetch: false`, and the doc says why — *"the local
42
+ * backend does not block private-network targets; do not enable web_fetch where it can reach
43
+ * sensitive internal ones."* A card is model-written code firing on a reader's keystrokes, so
44
+ * re-opening from here what the host closed for its own tools is not ours to do.
45
+ */
46
+ export const WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The ui4a path contract — the single place that decides what counts as a canvas.
3
+ * Shared by both halves; never re-derive these patterns with an inline regex.
4
+ */
5
+
6
+ /**
7
+ * Where generated files live, under the workspace's own dsh directory.
8
+ *
9
+ * `.dsh/` is the harness's project convention, not ours — `dsh-skill-filesystem` reads
10
+ * `join(projectRoot, ".dsh/skills")` and labels that source `project-dsh`. Sitting beside
11
+ * it keeps a plain `ls` of the user's repo clean and puts our files where they would look
12
+ * for anything dsh wrote. `ui4a` beneath it names the format, which is the honest nesting:
13
+ * this is a dsh plugin writing ui4a files, not a ui4a project with a dsh corner.
14
+ */
15
+ export const UI4A_DIR = ".dsh/ui4a";
16
+ export const CANVAS_DIR = `${UI4A_DIR}/canvases`;
17
+ export const CANVAS_SUFFIX = ".ui4a.tsx";
18
+ /**
19
+ * Info string of an inline fence, as the model writes it. Slash, not dash — matches
20
+ * ui4a-playground. Note the host's markdown renderer truncates it at the first
21
+ * non-identifier character, so it reaches the DOM as `ui4a`; nothing matches on it.
22
+ */
23
+ export const FENCE_LANG = "ui4a/tsx";
24
+
25
+ /**
26
+ * Import prefix for the capabilities the plugin lends to generated code.
27
+ *
28
+ * `$dsh/`, not `$ui4a/`: what these expose is the harness — the conversation, its model,
29
+ * its filesystem — and none of it is part of the ui4a rendering contract that `FENCE_LANG`
30
+ * and the canvas paths above define. A card written against them only runs inside dsh.
31
+ */
32
+ export const CAPABILITY_PREFIX = "$dsh";
33
+ export const capabilityModule = (group: string) => `${CAPABILITY_PREFIX}/${group}`;
34
+
35
+ /**
36
+ * Canvas ids are path segments, so anything that could escape the directory is not one.
37
+ *
38
+ * Stated as an exclusion rather than an allowlist: `[\w-]+` reads as "safe" but is really
39
+ * "ASCII", and a model answering in Chinese names the file in Chinese — `背单词.ui4a.tsx`
40
+ * was silently not a canvas, so the panel never opened and the reply still said it had.
41
+ * What actually has to be barred is separators and traversal; the rest is a filename.
42
+ */
43
+ const CANVAS_ID = /^[^/\\.\s]+$/;
44
+
45
+ export const isCanvasId = (id: string) => CANVAS_ID.test(id);
46
+
47
+ export const canvasPath = (id: string) => `${CANVAS_DIR}/${id}${CANVAS_SUFFIX}`;
48
+ export const canvasChildDir = (id: string) => `${CANVAS_DIR}/${id}`;
49
+
50
+ /**
51
+ * Resolves a relative specifier written inside a canvas to a workspace path.
52
+ *
53
+ * `from` is the path the specifier was written in — the canvas file itself, or one of its
54
+ * children — because **a relative specifier is relative to its importer, not to the canvas
55
+ * root**. The entry writes `./<id>/board`; a child of that entry writes `./types` for its
56
+ * sibling, and resolving both against the canvases directory sends the second one nowhere.
57
+ * Measured on a real split: the model produced 7 files whose cross-imports are all sibling
58
+ * form, and every one of them resolved to null before `from` existed.
59
+ *
60
+ * Every segment goes through the same exclusion test as an id, and the result must stay
61
+ * inside `canvasChildDir(id)` — `..` is rejected outright rather than normalised, so there
62
+ * is no arithmetic that could walk out.
63
+ *
64
+ * Returns null for anything outside that shape rather than throwing: the caller is a route
65
+ * answering an arbitrary page, and a bad specifier is a 400, not a crash.
66
+ */
67
+ export function canvasChildPath(id: string, specifier: string, from?: string): string | null {
68
+ if (!isCanvasId(id)) return null;
69
+ const segments = specifier.replace(/^\.\//, "").split("/");
70
+ if (segments.length === 0 || !segments.every(isCanvasId)) return null;
71
+ const root = canvasChildDir(id);
72
+ // No `from`, or one naming the entry file: the specifier is written beside the canvas, so
73
+ // it must open with the id. With a `from` inside the child directory, it is written beside
74
+ // that file instead and the id never appears.
75
+ const within = from === undefined ? null : from.replace(/\\/g, "/").split(`${root}/`)[1];
76
+ if (within === undefined || within === null) {
77
+ if (segments.length < 2 || segments[0] !== id) return null;
78
+ return `${root}/${segments.slice(1).join("/")}`;
79
+ }
80
+ const dir = within.split("/").slice(0, -1);
81
+ return [root, ...dir, ...segments].join("/");
82
+ }
83
+
84
+ /**
85
+ * Reduces a path to its workspace-relative form.
86
+ *
87
+ * Tool arguments carry absolute paths, so the contract is matched on the trailing
88
+ * `ui4a/canvases/…` portion rather than anchored at the string start.
89
+ */
90
+ const normalize = (path: string) => {
91
+ const at = path.replace(/\\/g, "/").lastIndexOf(`${CANVAS_DIR}/`);
92
+ return at === -1 ? path.replace(/^\.?\//, "") : path.slice(at);
93
+ };
94
+
95
+ /** The canvas id of an entry path, or null when the path is not a canvas entry. */
96
+ export function canvasIdOf(path: string): string | null {
97
+ const relative = normalize(path);
98
+ if (!relative.startsWith(`${CANVAS_DIR}/`) || !relative.endsWith(CANVAS_SUFFIX)) return null;
99
+ const id = relative.slice(CANVAS_DIR.length + 1, -CANVAS_SUFFIX.length);
100
+ return isCanvasId(id) ? id : null;
101
+ }
102
+
103
+ /** The owning canvas of any path under the contract — entry file or child module. */
104
+ export function owningCanvasIdOf(path: string): string | null {
105
+ const direct = canvasIdOf(path);
106
+ if (direct !== null) return direct;
107
+ const relative = normalize(path);
108
+ if (!relative.startsWith(`${CANVAS_DIR}/`)) return null;
109
+ const id = relative.slice(CANVAS_DIR.length + 1).split("/")[0];
110
+ return id !== undefined && isCanvasId(id) ? id : null;
111
+ }