@pi-unipi/fusion 2.19.0 → 2.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,7 +95,11 @@ default; `block:false` returns immediately and delivers a
95
95
  handoff. Background tasks keep that handoff open through their completion
96
96
  notification and any follow-up turn, so the report is not released at an
97
97
  intermediate checkpoint. If a prompt arrives while the child is processing,
98
- Fusion retries it once with pi's `followUp` streaming behavior.
98
+ Fusion retries it once with pi's `followUp` streaming behavior. Sidekick work
99
+ renders as ordinary tool activity — handoff ids, `agent_id`, and
100
+ `read_subagent` protocol text never reach the terminal. Detached
101
+ handoffs stay visible in a live sidekick widget above the editor until their
102
+ completion card arrives.
99
103
  `read_subagent({agent_id?, block?, timeout?})` reads or waits for a handoff.
100
104
 
101
105
  The child receives `UNIPI_FUSION_CHILD=1` and `UNIPI_SUBAGENT_CHILD=1`; the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.19.0",
3
+ "version": "2.19.1",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.19.0",
36
- "@pi-unipi/subagents": "2.19.0"
35
+ "@pi-unipi/core": "2.19.1",
36
+ "@pi-unipi/subagents": "2.19.1"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "^0.84.0",
package/src/index.ts CHANGED
@@ -40,6 +40,8 @@ import { estimateSavings } from "./savings.js";
40
40
  import { EDIT_NUDGE, bashNudge, leadPolicy, sidekickSystemPrompt, type FusionIdentity } from "./prompts.js";
41
41
  import { isTrivialShell, BASH_NUDGE_EVERY } from "./nudge.js";
42
42
  import { registerFusionTools } from "./tools.js";
43
+ import { shouldShowSidekickWidget } from "./sidekick-widget.js";
44
+ import { frameSidekick, markdownText, renderSidekickTranscript, sidekickWorkingHeader, type ThemeLike } from "./transcript.js";
43
45
 
44
46
  export const MODEL_COMMAND = `${UNIPI_PREFIX}model`;
45
47
  export const PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
@@ -118,6 +120,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
118
120
  let leadToolCalls = 0;
119
121
  let editNudgedThisTurn = false;
120
122
  let bashStreak = 0;
123
+ let attached = false;
124
+ let widgetTimer: NodeJS.Timeout | undefined;
125
+ let widgetTimerKind: "publish" | "clear" | undefined;
126
+ let widgetLastAt = 0;
121
127
 
122
128
  function identity(ctx: ExtensionContext): FusionIdentity {
123
129
  const reg = registryOf(ctx);
@@ -161,8 +167,79 @@ export default function fusionExtension(pi: ExtensionAPI): void {
161
167
  }
162
168
  }
163
169
 
170
+ function clearSidekickWidget(): void {
171
+ if (widgetTimer !== undefined) {
172
+ clearTimeout(widgetTimer);
173
+ widgetTimer = undefined;
174
+ widgetTimerKind = undefined;
175
+ }
176
+ const ctx = lastCtx;
177
+ if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
178
+ const clear = () => {
179
+ widgetTimer = undefined;
180
+ widgetTimerKind = undefined;
181
+ widgetLastAt = Date.now();
182
+ ctx.ui.setWidget("fusion-sidekick", undefined);
183
+ };
184
+ const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
185
+ if (wait === 0) clear();
186
+ else {
187
+ widgetTimerKind = "clear";
188
+ widgetTimer = setTimeout(clear, wait);
189
+ widgetTimer.unref();
190
+ }
191
+ }
192
+
193
+ function publishSidekickWidget(): void {
194
+ const ctx = lastCtx;
195
+ if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
196
+ const progress = runtime?.progress();
197
+ if (!runtime || !shouldShowSidekickWidget(runtime.isBusy(), attached) || !progress) {
198
+ ctx.ui.setWidget("fusion-sidekick", undefined);
199
+ return;
200
+ }
201
+ ctx.ui.setWidget("fusion-sidekick", (_tui, theme) => {
202
+ const themeLike = theme as unknown as ThemeLike & { bg: (color: string, text: string) => string };
203
+ return frameSidekick(themeLike, "working", renderSidekickTranscript(themeLike, {
204
+ events: progress.events,
205
+ droppedEvents: progress.droppedEvents,
206
+ header: sidekickWorkingHeader(themeLike, progress),
207
+ expanded: false,
208
+ isPartial: true,
209
+ renderText: markdownText,
210
+ }));
211
+ }, { placement: "aboveEditor" });
212
+ }
213
+
214
+ function publishSidekickWidgetLater(): void {
215
+ const ctx = lastCtx;
216
+ if (!ctx?.hasUI || typeof ctx.ui.setWidget !== "function") return;
217
+ if (widgetTimer !== undefined && widgetTimerKind === "publish") return;
218
+ if (widgetTimer !== undefined) {
219
+ clearTimeout(widgetTimer);
220
+ widgetTimer = undefined;
221
+ widgetTimerKind = undefined;
222
+ }
223
+ const wait = Math.max(0, 150 - (Date.now() - widgetLastAt));
224
+ const publish = () => {
225
+ widgetTimer = undefined;
226
+ widgetTimerKind = undefined;
227
+ widgetLastAt = Date.now();
228
+ publishSidekickWidget();
229
+ };
230
+ if (wait === 0) publish();
231
+ else {
232
+ widgetTimerKind = "publish";
233
+ widgetTimer = setTimeout(publish, wait);
234
+ widgetTimer.unref();
235
+ }
236
+ }
237
+
164
238
  function publishStatusLater(): void {
165
- if (lastCtx) publishStatus(lastCtx);
239
+ if (lastCtx) {
240
+ publishStatus(lastCtx);
241
+ publishSidekickWidgetLater();
242
+ }
166
243
  }
167
244
 
168
245
  function leadSessionId(ctx: ExtensionContext): string {
@@ -186,6 +263,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
186
263
  }
187
264
 
188
265
  function stopRuntime(): void {
266
+ clearSidekickWidget();
267
+ attached = false;
189
268
  runtime?.kill();
190
269
  runtime = undefined;
191
270
  leadToolCalls = 0;
@@ -206,8 +285,20 @@ export default function fusionExtension(pi: ExtensionAPI): void {
206
285
 
207
286
  registerFusionTools(pi, {
208
287
  getRuntime,
209
- onReport: (ctx) => publishStatus(ctx),
288
+ onReport: (ctx) => {
289
+ attached = false;
290
+ clearSidekickWidget();
291
+ publishStatus(ctx);
292
+ },
210
293
  onHandoffStart: (ctx) => publishStatus(ctx),
294
+ onAttach: () => {
295
+ attached = true;
296
+ clearSidekickWidget();
297
+ },
298
+ onDetach: () => {
299
+ attached = false;
300
+ publishSidekickWidgetLater();
301
+ },
211
302
  });
212
303
  pi.registerCommand("unipi:fusion-stats", {
213
304
  description: "Estimated Fusion savings (sidekick tokens priced at lead rates)",
@@ -0,0 +1,3 @@
1
+ export function shouldShowSidekickWidget(busy: boolean, attached: boolean): boolean {
2
+ return busy && !attached;
3
+ }
package/src/tools.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { Markdown, Text, type Component } from "@earendil-works/pi-tui";
2
- import { getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { Text, type Component } from "@earendil-works/pi-tui";
2
+ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
  import type { SidekickRuntime, HandoffProgress, HandoffReport } from "./sidekick-runtime.js";
5
- import { frameSidekick, renderSidekickTranscript, type ThemeLike as TranscriptTheme } from "./transcript.js";
5
+ import { duration, markdownText, renderSidekickTranscript, sidekickWorkingHeader } from "./transcript.js";
6
6
 
7
7
  const SidekickParams = Type.Object({
8
8
  message: Type.String({ description: "A concrete implementation or verification brief for the sidekick" }),
@@ -18,10 +18,8 @@ export interface FusionToolDeps {
18
18
  getRuntime: (ctx: ExtensionContext) => SidekickRuntime | undefined;
19
19
  onReport?: (ctx: ExtensionContext, report: HandoffReport) => void;
20
20
  onHandoffStart?: (ctx: ExtensionContext) => void;
21
- }
22
-
23
- function duration(ms: number): string {
24
- return `${(ms / 1000).toFixed(1)}s`;
21
+ onAttach?: (ctx: ExtensionContext) => void;
22
+ onDetach?: (ctx: ExtensionContext) => void;
25
23
  }
26
24
 
27
25
  function firstLine(value: string): string {
@@ -100,16 +98,26 @@ function completionMessage(report: HandoffReport): { customType: string; content
100
98
 
101
99
  type ThemeLike = {
102
100
  fg: (color: string, text: string) => string;
103
- bg: (color: string, text: string) => string;
104
101
  bold: (text: string) => string;
105
102
  };
106
103
 
107
- function transcriptTheme(theme: ThemeLike): TranscriptTheme {
108
- return { fg: (color, text) => theme.fg(color, text), bold: (text) => theme.bold(text) };
109
- }
110
-
111
- function markdownText(markdown: string): Markdown {
112
- return new Markdown(markdown, 0, 0, getMarkdownTheme());
104
+ // Model-facing result text carries handoff ids and protocol instructions the
105
+ // lead needs; none of it may reach the terminal. Anything rendered as plain
106
+ // content goes through this first.
107
+ const HANDOFF_ID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
108
+ function displayText(text: string): string {
109
+ return text
110
+ .replace(/<\/?subagent_completion_notification[^>]*>/g, "")
111
+ .replace(/use `?read_subagent`?[^.\n]*\.?/gi, "")
112
+ .replace(HANDOFF_ID, "")
113
+ .replace(/agent_id[ =]?"?"?/g, "")
114
+ .replace(/read_subagent\([^)]*\)/g, "read_subagent")
115
+ .replace(/\(\s*\)/g, "")
116
+ .replace(/[ \t]{2,}/g, " ")
117
+ .replace(/\bHandoff\s*(?=(is|was|failed|aborted|started)\b)/g, "The handoff ")
118
+ .replace(/ for\s*\./g, ".")
119
+ .replace(/ +$/gm, "")
120
+ .trim();
113
121
  }
114
122
 
115
123
  function contentText(content: unknown): string {
@@ -117,18 +125,29 @@ function contentText(content: unknown): string {
117
125
  return content.map((part) => typeof part === "object" && part !== null && typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "").filter(Boolean).join("\n");
118
126
  }
119
127
 
128
+ function backgroundComponent(theme: ThemeLike): Component {
129
+ return new Text(`${theme.fg("accent", theme.bold("◆ sidekick"))} ${theme.fg("dim", "· continuing in background")}`, 0, 0);
130
+ }
131
+
132
+ type ToolDetails = Partial<HandoffReport> & { progress?: HandoffProgress; background?: boolean; id?: string };
133
+
134
+ function reportHeader(theme: ThemeLike, label: string, report: HandoffReport | undefined): string {
135
+ const status = report?.status ?? "done";
136
+ return `${theme.fg(status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`${label} ${status}`))} ${theme.fg("dim", report ? `· ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)} · in ${String(report.usage.input)} / out ${String(report.usage.output)} tokens` : "")}`;
137
+ }
138
+
120
139
  function renderToolTranscript(result: { content?: unknown; details?: unknown; isError?: boolean }, options: { expanded?: boolean }, theme: ThemeLike, label: "sidekick" | "read_subagent"): Component {
121
- const details = result.details as (HandoffReport & { progress?: HandoffProgress }) | { progress?: HandoffProgress } | undefined;
140
+ const details = result.details as ToolDetails | undefined;
141
+ if (details?.background === true) return backgroundComponent(theme);
142
+ const hasProgress = details !== undefined && "progress" in details;
122
143
  const progress = details?.progress;
144
+ if (hasProgress && progress === undefined) return backgroundComponent(theme);
123
145
  const report = progress ? undefined : details as HandoffReport | undefined;
124
146
  const events = progress?.events ?? report?.events;
125
147
  const partial = progress !== undefined;
126
- const status = partial ? "working" : report?.status === "completed" && !result.isError ? "completed" : "error";
127
- if (!events) return frameSidekick(theme, status, new Text(contentText(result.content), 0, 0));
128
- const header = partial
129
- ? `${theme.fg("accent", theme.bold(`◆ ${label} working`))} ${theme.fg("dim", `· ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)}`)}`
130
- : `${theme.fg(report?.status === "completed" ? "success" : "error", "◆")} ${theme.fg("accent", theme.bold(`${label} ${report?.status ?? "done"}`))} ${theme.fg("dim", report ? `· ${report.id} · ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)} · in ${String(report.usage.input)} / out ${String(report.usage.output)} tokens` : "")}`;
131
- return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
148
+ if (!events) return new Text(displayText(contentText(result.content)), 0, 0);
149
+ const header = partial ? sidekickWorkingHeader(theme, progress, label) : reportHeader(theme, label, report);
150
+ return renderSidekickTranscript(theme, {
132
151
  events,
133
152
  droppedEvents: progress?.droppedEvents,
134
153
  header,
@@ -136,20 +155,19 @@ function renderToolTranscript(result: { content?: unknown; details?: unknown; is
136
155
  isPartial: partial,
137
156
  report,
138
157
  renderText: markdownText,
139
- }));
158
+ });
140
159
  }
141
160
 
142
161
  function renderCompletionCard(theme: ThemeLike, report: HandoffReport | undefined): Component {
143
- const status = report?.status === "completed" ? "completed" : "error";
144
- if (!report) return frameSidekick(theme, "error", new Text(`${theme.fg("accent", "◆")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0));
145
- return frameSidekick(theme, status, renderSidekickTranscript(transcriptTheme(theme), {
162
+ if (!report) return new Text(`${theme.fg("accent", "")} ${theme.fg("accent", theme.bold("sidekick done"))}`, 0, 0);
163
+ return renderSidekickTranscript(theme, {
146
164
  events: report.events ?? [],
147
- header: `${theme.fg(report.status === "completed" ? "success" : "error", "")} ${theme.fg("accent", theme.bold(`sidekick ${report.status}`))} ${theme.fg("dim", `· ${report.id} · ${String(report.toolCalls)} tool calls · ${duration(report.durationMs)}`)}`,
165
+ header: reportHeader(theme, "sidekick", report),
148
166
  expanded: false,
149
167
  isPartial: false,
150
168
  report,
151
169
  renderText: markdownText,
152
- }));
170
+ });
153
171
  }
154
172
 
155
173
  export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): void {
@@ -161,7 +179,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
161
179
  description: "Hand off work to your persistent sidekick subagent (one per session; context and shells persist across handoffs; runs on the same machine). block:true (default) waits and returns the report. block:false returns immediately and the report arrives later as a <subagent_completion_notification>. Calling again while a handoff is running injects the message as an interrupt rather than starting a second sidekick.",
162
180
  parameters: SidekickParams,
163
181
  renderShell: "self",
164
- renderCall: (args, theme) => frameSidekick(theme as unknown as ThemeLike, "working", new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0)),
182
+ renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ sidekick"))} ${theme.fg("dim", firstLine(String(args.message)).slice(0, 100))}`, 0, 0),
165
183
  renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "sidekick"),
166
184
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
167
185
  const runtime = deps.getRuntime(ctx);
@@ -174,17 +192,20 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
174
192
  deps.onReport?.(ctx, report);
175
193
  pi.sendMessage(completionMessage(report) as never, { deliverAs: "followUp", triggerTurn: true } as never);
176
194
  }).catch(() => undefined);
177
- return result(`Handoff ${handoff.id} started in the background. You will receive a <subagent_completion_notification agent_id="${handoff.id}"> when it finishes; use read_subagent to wait.`);
195
+ deps.onDetach?.(ctx);
196
+ return result(`Handoff ${handoff.id} started in the background. You will receive a <subagent_completion_notification agent_id="${handoff.id}"> when it finishes; use read_subagent to wait.`, { background: true, id: handoff.id });
178
197
  }
198
+ deps.onAttach?.(ctx);
179
199
  const waited = await waitForReport(runtime, handoff.id, handoff.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined);
180
200
  if (waited.report) {
181
201
  deps.onReport?.(ctx, waited.report);
182
202
  return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
183
203
  }
204
+ deps.onDetach?.(ctx);
184
205
  if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
185
206
  if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
186
- if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${handoff.id}) was working. The handoff continues in the background. Act on the user's message first, then call read_subagent({agent_id:"${handoff.id}", block:true}) to collect the report or sidekick({message}) to redirect it.\n${waited.interrupted}`);
187
- return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}`);
207
+ if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${handoff.id}) was working. The handoff continues in the background. Act on the user's message first, then call read_subagent({agent_id:"${handoff.id}", block:true}) to collect the report or sidekick({message}) to redirect it.\n${waited.interrupted}`, { progress: runtime.progress(handoff.id), id: handoff.id });
208
+ return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}`, { progress: runtime.progress(handoff.id), id: handoff.id });
188
209
  },
189
210
  });
190
211
 
@@ -194,7 +215,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
194
215
  description: "Read a sidekick handoff report by agent_id (omit for the latest). block:true waits for completion (default timeout 2700s when omitted); block:false returns the current progress snapshot immediately.",
195
216
  parameters: ReadSubagentParams,
196
217
  renderShell: "self",
197
- renderCall: (args, theme) => frameSidekick(theme as unknown as ThemeLike, "working", new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.agent_id ?? "latest")}`, 0, 0)),
218
+ renderCall: (args, theme) => new Text(`${theme.fg("toolTitle", theme.bold("◆ read_subagent"))} ${theme.fg("dim", args.block === false ? "· snapshot" : "· waiting")}`, 0, 0),
198
219
  renderResult: (result, options, theme) => renderToolTranscript(result, options, theme as unknown as ThemeLike, "read_subagent"),
199
220
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
200
221
  const runtime = deps.getRuntime(ctx);
@@ -205,17 +226,19 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
205
226
  const selected = runtime.reports.get(id);
206
227
  if (selected) return result(reportText(selected), selected, selected.status !== "completed");
207
228
  if (id !== latest.id) return result(`No sidekick handoff found for ${id}.`, undefined, true);
208
- if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`);
229
+ if (params.block !== true) return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
230
+ deps.onAttach?.(ctx);
209
231
  const timeoutMs = (params.timeout ?? 2700) * 1000;
210
232
  const waited = await waitForReport(runtime, id, latest.done, signal, ctx, onUpdate ? (update) => onUpdate(update as never) : undefined, timeoutMs);
211
233
  if (waited.report) {
212
234
  deps.onReport?.(ctx, waited.report);
213
235
  return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
214
236
  }
237
+ deps.onDetach?.(ctx);
215
238
  if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
216
239
  if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
217
- if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}`);
218
- return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`);
240
+ if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}`, { progress: runtime.progress(id), id });
241
+ return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`, { progress: runtime.progress(id), id });
219
242
  },
220
243
  });
221
244
 
package/src/transcript.ts CHANGED
@@ -1,11 +1,24 @@
1
- import { Box, Container, Text, type Component } from "@earendil-works/pi-tui";
2
- import type { SidekickEvent } from "./sidekick-runtime.js";
1
+ import { Box, Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
2
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
3
+ import type { HandoffProgress, SidekickEvent } from "./sidekick-runtime.js";
3
4
 
4
5
  export interface ThemeLike {
5
6
  fg: (color: string, text: string) => string;
6
7
  bold: (text: string) => string;
7
8
  }
8
9
 
10
+ export function duration(ms: number): string {
11
+ return `${(ms / 1000).toFixed(1)}s`;
12
+ }
13
+
14
+ export function markdownText(markdown: string): Component {
15
+ return new Markdown(markdown, 0, 0, getMarkdownTheme());
16
+ }
17
+
18
+ export function sidekickWorkingHeader(theme: ThemeLike, progress: Pick<HandoffProgress, "toolCalls" | "startedAt">, label = "sidekick"): string {
19
+ return `${theme.fg("accent", theme.bold(`◆ ${label} working`))} ${theme.fg("dim", `· ${String(progress.toolCalls)} tool calls · ${duration(Date.now() - progress.startedAt)}`)}`;
20
+ }
21
+
9
22
  export class RailComponent implements Component {
10
23
  constructor(private readonly inner: Component, private readonly rail: string) {}
11
24