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,109 @@
1
+ /**
2
+ * Rewrites a canvas's relative imports into blob URLs, before the source is compiled.
3
+ *
4
+ * `src/prompt.ts` tells the model that a canvas's sub-pages live in `<id>/` and are imported
5
+ * with relative paths, and the model does exactly that. But a card is imported as a blob URL,
6
+ * and `blob:` is not a hierarchical scheme — the browser rejects `./tarot/deck` with
7
+ * "Invalid relative url or base scheme isn't hierarchical" before any import map is consulted,
8
+ * so `setImportMap` cannot help. Measured: an import map keyed on the relative specifier fails
9
+ * identically, because resolution against the importer's URL happens first.
10
+ *
11
+ * Replacing the specifier with the child's own blob URL removes the question: an absolute URL
12
+ * has no base to resolve against. Children may import their siblings, which works for the same
13
+ * reason once they too have been rewritten.
14
+ */
15
+ /**
16
+ * Matches the specifier of a static import or re-export; the second capture is the specifier.
17
+ *
18
+ * NOT global, and that is load-bearing. It was `/g`, shared by all three call sites, and `.test`
19
+ * on a global regex leaves `lastIndex` past the match it found — so `importsSibling` returning
20
+ * TRUE made the very next `matchAll` on the same string return ZERO. That is the order
21
+ * `CanvasPanel` calls them in: ask whether there are sibling imports, then go resolve them. The
22
+ * panel found none to resolve and the card rendered without its sub-pages.
23
+ *
24
+ * `matchAll` requires `/g`, so it gets its own copy; the shared one stays sticky-free and no
25
+ * caller has to remember to reset anything.
26
+ */
27
+ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*)["'](\.[^"']*)["']/;
28
+ const SPECIFIER_ALL = new RegExp(SPECIFIER, "g");
29
+
30
+ /**
31
+ * Whether the card imports a sibling at all — the cheap question `CanvasPanel` asks before
32
+ * paying for a resolve pass. It had its own copy of the regex, identical but for the `g` flag;
33
+ * a widening applied to one and not the other means the panel never calls `inlineSubPages` and
34
+ * the card silently renders without its sub-pages. Sharing one pattern fixed that and introduced
35
+ * the `lastIndex` bug above — hence two derived regexes rather than two literals.
36
+ */
37
+ export const importsSibling = (code: string) => SPECIFIER.test(code);
38
+
39
+ /**
40
+ * @param read fetches one child by its specifier, returning its source and the real filename
41
+ * it was found under — the compiler picks its syntax from the extension, and a specifier is
42
+ * written without one, so passing the specifier makes a `.ts` file fail to parse.
43
+ * @param compile turns one child's TSX into JS. Children go through the same compiler as the
44
+ * card, so a sub-page may be TSX and may itself import a sibling.
45
+ * @param urls collects every blob created, so the caller can revoke them with the surface.
46
+ */
47
+ export async function inlineSubPages(code: string, entry: string, read: (specifier: string, from: string) => Promise<{ source: string; filename: string } | null>, compile: (filename: string, source: string) => Promise<string>, urls: string[]): Promise<string> {
48
+ // Keyed by the RESOLVED filename, never by the specifier: `./types` written in two different
49
+ // child files is two different targets, and a specifier-keyed map silently serves the first
50
+ // one to both. Measured on a real split — the model gives every child a sibling import.
51
+ const sources = new Map<string, { source: string; filename: string; specifiers: Map<string, string> }>();
52
+ const missing = new Set<string>();
53
+
54
+ const specifiersIn = (source: string) => new Set([...source.matchAll(SPECIFIER_ALL)].map((match) => match[2]));
55
+
56
+ // Collected breadth-first, then compiled in one pass. Resolving a child's own imports
57
+ // *during* its fetch deadlocks on a cycle — a imports b imports a, and each awaits the
58
+ // other's URL forever. Measured: it hung. Reading every reachable child first, and only
59
+ // then handing out URLs, has no such wait.
60
+ let frontier: { specifier: string; from: string }[] = [...specifiersIn(code)].map((specifier) => ({ specifier, from: entry }));
61
+ const entryTargets = new Map<string, string>();
62
+ while (frontier.length > 0) {
63
+ const bodies = await Promise.all(frontier.map(async (want) => [want, await read(want.specifier, want.from)] as const));
64
+ const next: { specifier: string; from: string }[] = [];
65
+ for (const [want, found] of bodies) {
66
+ const targets = want.from === entry ? entryTargets : sources.get(want.from)?.specifiers;
67
+ if (found === null) {
68
+ missing.add(want.specifier);
69
+ continue;
70
+ }
71
+ targets?.set(want.specifier, found.filename);
72
+ if (sources.has(found.filename)) continue;
73
+ sources.set(found.filename, { ...found, specifiers: new Map() });
74
+ for (const specifier of specifiersIn(found.source)) next.push({ specifier, from: found.filename });
75
+ }
76
+ frontier = next;
77
+ }
78
+
79
+ // A blob's contents are fixed at creation, so a child can only be minted once every sibling
80
+ // it imports already has a URL. Repeat until nothing moves: a cycle never becomes mintable
81
+ // and keeps its original specifiers, failing exactly as it does today rather than hanging.
82
+ const urlFor = new Map<string, string>();
83
+ for (const filename of sources.keys()) urlFor.set(filename, "");
84
+
85
+ // Rewrites through `SPECIFIER_ALL`, not `replaceAll`, so only an actual import position moves.
86
+ // A bare `replaceAll("./board")` also rewrites the string in `const label = "./board"` — the
87
+ // card then renders a blob URL as its label, or passes one where a path was meant. No corpus
88
+ // card does this today; the regex that finds the imports already knows the difference, so
89
+ // there is no reason to throw that away when putting them back.
90
+ const rewrite = (source: string, specifiers: Map<string, string>) =>
91
+ source.replace(SPECIFIER_ALL, (whole, lead: string, specifier: string) => {
92
+ const url = urlFor.get(specifiers.get(specifier) ?? "");
93
+ return url === undefined || url === "" ? whole : `${lead}${JSON.stringify(url)}`;
94
+ });
95
+
96
+ for (let progress = true; progress;) {
97
+ progress = false;
98
+ const ready = [...sources].filter(([filename, found]) => urlFor.get(filename) === "" && [...found.specifiers.values()].every((dep) => urlFor.get(dep) !== ""));
99
+ const built = await Promise.all(ready.map(async ([filename, found]) => [filename, await compile(filename, rewrite(found.source, found.specifiers))] as const));
100
+ for (const [filename, compiled] of built) {
101
+ const url = URL.createObjectURL(new Blob([compiled], { type: "text/javascript" }));
102
+ urlFor.set(filename, url);
103
+ urls.push(url);
104
+ progress = true;
105
+ }
106
+ }
107
+
108
+ return rewrite(code, entryTargets);
109
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Open/close state for a menu, closed by a click anywhere else.
3
+ *
4
+ * `pointerdown` on the document rather than a click handler on a backdrop: a backdrop
5
+ * element would sit over the canvas and swallow the first click into it.
6
+ */
7
+ import { useEffect, useRef, useState } from "react";
8
+
9
+ /**
10
+ * Closes on a pointerdown anywhere but `anchor`, while `open`. Returns its own disposer.
11
+ *
12
+ * Split out of the hook so the four things that matter are testable without a renderer: that it
13
+ * does not listen while closed, that an outside press closes, that a press inside the anchor
14
+ * does **not**, and that it unsubscribes. A `pointerdown` listener on the document rather than a
15
+ * backdrop element, because a backdrop would sit over the canvas and swallow the first click
16
+ * into it.
17
+ */
18
+ export function dismissOnOutsidePointer(open: boolean, anchor: HTMLElement | null, close: () => void): (() => void) | undefined {
19
+ if (!open) return undefined;
20
+ const onPointerDown = (event: Event) => {
21
+ // The toggle button lives inside the anchor, so ignoring the anchor is what keeps a click on
22
+ // it from closing here and reopening in the button's own handler.
23
+ if (anchor?.contains(event.target as Node) === true) return;
24
+ close();
25
+ };
26
+ document.addEventListener("pointerdown", onPointerDown);
27
+ return () => document.removeEventListener("pointerdown", onPointerDown);
28
+ }
29
+
30
+ export function useDismissable() {
31
+ const [open, setOpen] = useState(false);
32
+ const anchor = useRef<HTMLDivElement>(null);
33
+
34
+ useEffect(() => dismissOnOutsidePointer(open, anchor.current, () => setOpen(false)), [open]);
35
+
36
+ return { open, setOpen, anchor };
37
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Browser half: renders ui4a TSX the model writes, inline in the transcript and in a
3
+ * canvas panel beside it.
4
+ * @module dsh-generative-ui/client
5
+ */
6
+ import { createElement } from "react";
7
+ import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
8
+ import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
9
+ import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
10
+ import { GenUISurface } from "./runtime/GenUISurface.tsx";
11
+ import { cardRendered, reportCardError } from "./runtime/report-error.ts";
12
+ import { disposeCompiler } from "./runtime/compiler.ts";
13
+ import { dropSharedCompiler } from "./runtime/GenUISurface.tsx";
14
+ import { disposeRegistry } from "./runtime/registry.ts";
15
+ import { registerUi4aHost, releaseBindings, localImports } from "./runtime/bindings.ts";
16
+ import { claimInlineFences } from "./runtime/inline-fence.ts";
17
+ import { parseUi4aSegments, type Ui4aSegment } from "./runtime/segments.ts";
18
+ import { warmCompiler } from "./runtime/compiler.ts";
19
+ import { chatNodes, perNode, type ChatNodeView } from "./session.ts";
20
+ import { mountCanvasHost } from "./canvas/index.ts";
21
+ import { toolCallsOf, type CallBlock, type ToolCallView } from "./canvas/collect.ts";
22
+ import { canvasIdOf } from "../contract.ts";
23
+
24
+ export const inject = ["sessions"];
25
+
26
+ /** Re-exported so `bun run smoke` can build the synthesized blob modules and parse them. */
27
+ export { localImports };
28
+
29
+ /** Assistant text blocks, whose fences are the inline sources. */
30
+ type AssistantNodeData = { blocks?: readonly { kind: string; text?: string }[] };
31
+
32
+ export function apply(ctx: ClientContext): void {
33
+ // Compiling anything pays a ~400 ms wasm init. Doing it now means the first fence the
34
+ // user actually sees does not, and an idle tab is the cheapest possible moment for it.
35
+ void warmCompiler();
36
+
37
+ /**
38
+ * Every ui4a fence in the current session's assistant prose, straight from the log.
39
+ *
40
+ * The host's markdown renderer withholds a fence's info string until the closing fence
41
+ * arrives, so the DOM cannot identify a half-written ui4a block. The raw text has the
42
+ * opening fence from its first token, which is what makes inline rendering streamable.
43
+ */
44
+ const segmentsOf = perNode(
45
+ (node) => `${node.anchorSeq}:${textOf(node).length}`,
46
+ (node) => parseUi4aSegments(textOf(node)),
47
+ );
48
+ const segments = (): readonly Ui4aSegment[] => segmentsOf(chatNodes(ctx)).flat();
49
+
50
+ /**
51
+ * Every tool call in the current session, in log order — the canvas source.
52
+ *
53
+ * `tool-call` nodes carry a lifecycle `root` (not the assistant's block list), and the
54
+ * root's `call.argsRaw` grows while the call streams, which is what makes a canvas
55
+ * render as it is written.
56
+ */
57
+ const callsOf = perNode(
58
+ (node) => `${node.anchorSeq}:${callsKeyOf(node)}`,
59
+ (node) => (node.kind === "tool-call" ? toolCallsOf(node.data) : []),
60
+ );
61
+ const calls = (): readonly ToolCallView[] => {
62
+ const nodes = chatNodes(ctx);
63
+ // Node iteration order is unspecified; log order decides which write wins.
64
+ return callsOf(nodes)
65
+ .map((calls, index) => ({ calls, seq: nodes[index]?.anchorSeq ?? 0 }))
66
+ .toSorted((a, b) => a.seq - b.seq)
67
+ .flatMap((entry) => entry.calls);
68
+ };
69
+
70
+ /** The current session's workspace, which canvas file reads resolve against. */
71
+ const cwd = (): string | undefined => {
72
+ const list = ctx.sessions.list.getSnapshot();
73
+ return list.current === undefined ? undefined : list.byId[list.current]?.cwd;
74
+ };
75
+
76
+ /**
77
+ * Identity of the open session, so a dismissed canvas stays dismissed only there.
78
+ * Returns the branded `SessionId` rather than a plain string: `sessions.scope()` needs it.
79
+ */
80
+ const currentSession = () => ctx.sessions.list.getSnapshot().current;
81
+ const sessionId = (): string => currentSession() ?? "";
82
+
83
+ // Revoking on teardown is safe: a blob module that was already imported keeps working after
84
+ // its URL is revoked (the module graph holds it), so this only reclaims URLs nothing can
85
+ // reach any more. Without it every HMR round leaks one per registered specifier.
86
+ ctx.effect(() => disposeRegistry, "dsh-generative-ui: blob module URLs");
87
+ // The wasm half of the same problem: ~16MB per instance, one per HMR round, and upstream
88
+ // offers no dispose — dropping the reference is all there is (see `disposeCompiler`).
89
+ ctx.effect(
90
+ () => () => {
91
+ disposeCompiler();
92
+ dropSharedCompiler();
93
+ },
94
+ "dsh-generative-ui: tsx wasm instance",
95
+ );
96
+ // What `$dsh/chat` calls into. A nested fiber, not a static inject: every name in
97
+ // `inject` is a hard dependency, and a profile without `conversation` would otherwise
98
+ // take the whole plugin down rather than just this one capability.
99
+ ctx.inject(["conversation"], (scoped) => {
100
+ scoped.effect(() => {
101
+ // `conversation` is scope-addressed: reading it off the plugin's own context sends
102
+ // into no session and rejects. The scope has to come from `sessions.scope(id)`, and
103
+ // resolved per call rather than once — the reader switches sessions under us.
104
+ //
105
+ // `send` rejects on business failures and the caller is a generated card that cannot
106
+ // handle it, so surface it rather than dropping it: a click that goes nowhere looks
107
+ // exactly like a click that was never wired up.
108
+ const release = registerUi4aHost({
109
+ cwd,
110
+ sessionId,
111
+ send: (text) => {
112
+ const id = currentSession();
113
+ const session = id === undefined ? undefined : scoped.sessions.scope(id);
114
+ if (session === undefined) return void console.error("[dsh-generative-ui] $dsh/chat: no session to send into");
115
+ // The scoped context is minted by the host and carries its own inject set, so our
116
+ // outer declaration does not reach it — reading `conversation` off it directly
117
+ // throws `cannot get property "conversation" without inject`. One more inject on
118
+ // that context is what makes the property readable.
119
+ session.inject(["conversation"], (addressed) => {
120
+ void addressed.conversation.send(text).catch((error: unknown) => console.error("[dsh-generative-ui] $dsh/chat send failed", error));
121
+ });
122
+ },
123
+ });
124
+ return () => {
125
+ release();
126
+ releaseBindings();
127
+ };
128
+ }, "dsh-generative-ui: $dsh host");
129
+ });
130
+ // A card that fails to compile used to be a red panel the reader saw and the model never did.
131
+ // `onError` fires only for a failure that survived settling and retries, so this is the real
132
+ // ones — see `report-error.ts` for why it is once per message and why it says it is automatic.
133
+ const sendToModel = (text: string) => {
134
+ const id = currentSession();
135
+ const session = id === undefined ? undefined : ctx.sessions.scope(id);
136
+ if (session === undefined) return;
137
+ session.inject(["conversation"], (addressed) => {
138
+ void addressed.conversation.send(text).catch((error: unknown) => console.error("[dsh-generative-ui] card error report failed", error));
139
+ });
140
+ };
141
+
142
+ // Mounted inside the effect, not beside it: `mountCanvasHost` reaches for MutationObserver
143
+ // straight away, and doing that during registration is exactly what smoke rejects.
144
+ let showCanvas: ((id: string) => void) | null = null;
145
+ ctx.effect(() => {
146
+ const host = mountCanvasHost({ calls, cwd, sessionId, onCardError: (message, phase) => reportCardError(sendToModel, message, phase), onCardRendered: cardRendered });
147
+ showCanvas = host.show;
148
+ return () => {
149
+ showCanvas = null;
150
+ host.dispose();
151
+ };
152
+ }, "dsh-generative-ui: canvas column");
153
+
154
+ // The transcript's file links and the "产物" chips call `workspaces.openPath`, which hands
155
+ // the path to the OS — so clicking a canvas the model just wrote opened it in an editor
156
+ // rather than in the panel three inches to the right. Wrapping the method routes canvases
157
+ // to the panel and forwards everything else untouched.
158
+ ctx.inject(["workspaces"], (scoped) => {
159
+ scoped.effect(() => {
160
+ const workspaces = scoped.workspaces as { openPath?: (path: string) => Promise<void> };
161
+ // Wrapping someone else's method is a bet on its shape. Losing that bet here would
162
+ // throw during registration and take the whole plugin down, so a host without it
163
+ // simply keeps its own behaviour.
164
+ if (typeof workspaces.openPath !== "function") return () => {};
165
+ const original = workspaces.openPath.bind(workspaces);
166
+ workspaces.openPath = async (path: string) => {
167
+ const id = canvasIdOf(path);
168
+ // No panel mounted (headless, or torn down): the OS opener is still the right answer.
169
+ if (id === null || showCanvas === null) return original(path);
170
+ showCanvas(id);
171
+ };
172
+ // Restore rather than delete: another plugin may have wrapped it after us, and
173
+ // deleting the own-property would expose theirs — or the prototype's — instead.
174
+ return () => {
175
+ workspaces.openPath = original;
176
+ };
177
+ }, "dsh-generative-ui: canvas links open the panel");
178
+ });
179
+ ctx.effect(
180
+ () =>
181
+ claimInlineFences({
182
+ segments,
183
+ render: ({ code, streaming }) => createElement(GenUISurface, { code, streaming, onError: (error, phase) => reportCardError(sendToModel, error.message, phase), onRendered: cardRendered }),
184
+ }),
185
+ "dsh-generative-ui: inline fences",
186
+ );
187
+ }
188
+
189
+ /** All assistant prose in one node, concatenated; empty for every other node kind. */
190
+ export function textOf(node: ChatNodeView): string {
191
+ const blocks = (node.data as AssistantNodeData | undefined)?.blocks;
192
+ if (blocks === undefined) return "";
193
+ let text = "";
194
+ for (const block of blocks) if (block.kind === "text" && block.text !== undefined) text += block.text;
195
+ return text;
196
+ }
197
+
198
+ /**
199
+ * Cache key for a `tool-call` node: how far its arguments have streamed, plus which calls
200
+ * have settled. Read straight off the node rather than through `toolCallsOf`, so computing
201
+ * the key does not repeat the work the cache exists to avoid.
202
+ *
203
+ * The settled flags are part of the key, not a detail: a call's `argsRaw` is already
204
+ * complete when `tool-result` arrives, so a length-only key would never invalidate and the
205
+ * cached view would claim `streaming` forever — a canvas that never stops pulsing and an
206
+ * `edit` that never marks it stale.
207
+ */
208
+ export function callsKeyOf(node: ChatNodeView): string {
209
+ const parts: string[] = [];
210
+ const walk = (block: CallBlock | undefined): void => {
211
+ if (block === undefined) return;
212
+ parts.push(`${(block.call?.argsRaw ?? block.argsRaw)?.length ?? 0}${block.kind === "tool-result" ? "!" : ""}`);
213
+ for (const child of block.subCalls ?? []) walk(child);
214
+ };
215
+ walk((node.data as { root?: CallBlock } | undefined)?.root);
216
+ return parts.join(",");
217
+ }