@elabs-ai/components-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,314 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@elabs-ai/components-ui";
4
+ import type { CarouselApi } from "@elabs-ai/components-ui";
5
+ import { Carousel, CarouselContent, CarouselItem } from "@elabs-ai/components-ui";
6
+ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@elabs-ai/components-ui";
7
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
8
+ import { useLocale } from "@elabs-ai/components-ui";
9
+ import { ArrowLeftIcon, ArrowRightIcon, BadgeCheckIcon } from "lucide-react";
10
+ import type { ComponentProps } from "react";
11
+ import { createContext, useCallback, useContext, useEffect, useState } from "react";
12
+
13
+ export type InlineCitationProps = ComponentProps<"span">;
14
+
15
+ export const InlineCitation = ({ className, ...props }: InlineCitationProps) => (
16
+ <span className={cn("group inline items-center gap-1", className)} {...props} />
17
+ );
18
+
19
+ export type InlineCitationTextProps = ComponentProps<"span">;
20
+
21
+ export const InlineCitationText = ({ className, ...props }: InlineCitationTextProps) => (
22
+ <span className={cn("transition-colors group-hover:bg-accent", className)} {...props} />
23
+ );
24
+
25
+ export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
26
+
27
+ export const InlineCitationCard = (props: InlineCitationCardProps) => (
28
+ <HoverCard closeDelay={0} openDelay={0} {...props} />
29
+ );
30
+
31
+ /**
32
+ * A cited source: a bare string — a URL, or any opaque id / human label — or a
33
+ * labelled object.
34
+ *
35
+ * Non-URL sources are first-class. An agent citing a warehouse table, a document
36
+ * id, an uploaded file or a wiki page has no hostname to show, and must not have
37
+ * to synthesize a fake URL to satisfy the chip.
38
+ */
39
+ export type InlineCitationSourceRef =
40
+ | string
41
+ | {
42
+ /** Opaque identifier, shown when there is no `label` and no `url`. */
43
+ id?: string;
44
+ /** Human-readable label — wins over any hostname derived from `url`. */
45
+ label?: string;
46
+ /** Optional link; its hostname is the fallback label. */
47
+ url?: string;
48
+ };
49
+
50
+ /** The URL's hostname when `value` parses as one, else `value` itself. Never throws. */
51
+ const hostnameOrRaw = (value: string): string => {
52
+ try {
53
+ return new URL(value).hostname || value;
54
+ } catch {
55
+ return value;
56
+ }
57
+ };
58
+
59
+ /**
60
+ * The chip label for a source ref. Exported so consumers can render the same
61
+ * label elsewhere (a footnote list, a tooltip) without re-deriving the rules.
62
+ */
63
+ export const inlineCitationSourceLabel = (source: InlineCitationSourceRef): string => {
64
+ if (typeof source === "string") {
65
+ return hostnameOrRaw(source);
66
+ }
67
+ // `||` not `??` — an upstream field that is present but EMPTY (`label: ""`)
68
+ // must still fall through, or the chip renders with no label at all.
69
+ return source.label || (source.url ? hostnameOrRaw(source.url) : source.id || "unknown");
70
+ };
71
+
72
+ export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
73
+ sources: InlineCitationSourceRef[];
74
+ };
75
+
76
+ export const InlineCitationCardTrigger = ({
77
+ sources,
78
+ className,
79
+ ...props
80
+ }: InlineCitationCardTriggerProps) => (
81
+ <HoverCardTrigger asChild>
82
+ <Badge
83
+ // The 08 §C.1 evidence channel: the green wash marks "grounded" — green
84
+ // is reserved for evidence/answer/favourable (research 11 §B.4).
85
+ className={cn(
86
+ "ms-1 rounded-full border-success/40 bg-success/10 text-success-text",
87
+ className,
88
+ )}
89
+ variant="outline"
90
+ {...props}
91
+ >
92
+ <BadgeCheckIcon aria-hidden="true" className="size-3" />
93
+ {sources[0] ? (
94
+ <>
95
+ {inlineCitationSourceLabel(sources[0])} {sources.length > 1 && `+${sources.length - 1}`}
96
+ </>
97
+ ) : (
98
+ "unknown"
99
+ )}
100
+ </Badge>
101
+ </HoverCardTrigger>
102
+ );
103
+
104
+ export type EvidenceChipProps = InlineCitationCardTriggerProps;
105
+
106
+ /**
107
+ * EvidenceChip — the named front door for the inline evidence interaction
108
+ * (research 11 §B.4): the re-skinned `InlineCitationCardTrigger` (green
109
+ * "grounded" wash). Use inside an `InlineCitationCard` (HoverCard) — it reuses
110
+ * the citation carousel wholesale; it is not a new primitive.
111
+ */
112
+ export const EvidenceChip = InlineCitationCardTrigger;
113
+
114
+ export type InlineCitationCardBodyProps = ComponentProps<"div">;
115
+
116
+ export const InlineCitationCardBody = ({ className, ...props }: InlineCitationCardBodyProps) => (
117
+ <HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
118
+ );
119
+
120
+ const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
121
+
122
+ const useCarouselApi = () => {
123
+ const context = useContext(CarouselApiContext);
124
+ return context;
125
+ };
126
+
127
+ export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
128
+
129
+ export const InlineCitationCarousel = ({
130
+ className,
131
+ children,
132
+ ...props
133
+ }: InlineCitationCarouselProps) => {
134
+ const [api, setApi] = useState<CarouselApi>();
135
+
136
+ return (
137
+ <CarouselApiContext.Provider value={api}>
138
+ <Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
139
+ {children}
140
+ </Carousel>
141
+ </CarouselApiContext.Provider>
142
+ );
143
+ };
144
+
145
+ export type InlineCitationCarouselContentProps = ComponentProps<"div">;
146
+
147
+ export const InlineCitationCarouselContent = (props: InlineCitationCarouselContentProps) => (
148
+ <CarouselContent {...props} />
149
+ );
150
+
151
+ export type InlineCitationCarouselItemProps = ComponentProps<"div">;
152
+
153
+ export const InlineCitationCarouselItem = ({
154
+ className,
155
+ ...props
156
+ }: InlineCitationCarouselItemProps) => (
157
+ <CarouselItem className={cn("w-full space-y-2 p-4 ps-8", className)} {...props} />
158
+ );
159
+
160
+ export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
161
+
162
+ export const InlineCitationCarouselHeader = ({
163
+ className,
164
+ ...props
165
+ }: InlineCitationCarouselHeaderProps) => (
166
+ <div
167
+ className={cn(
168
+ "flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
169
+ className,
170
+ )}
171
+ {...props}
172
+ />
173
+ );
174
+
175
+ export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
176
+
177
+ export const InlineCitationCarouselIndex = ({
178
+ children,
179
+ className,
180
+ ...props
181
+ }: InlineCitationCarouselIndexProps) => {
182
+ const api = useCarouselApi();
183
+ const [current, setCurrent] = useState(0);
184
+ const [count, setCount] = useState(0);
185
+
186
+ const syncState = useCallback(() => {
187
+ if (!api) {
188
+ return;
189
+ }
190
+ setCount(api.scrollSnapList().length);
191
+ setCurrent(api.selectedScrollSnap() + 1);
192
+ }, [api]);
193
+
194
+ useEffect(() => {
195
+ if (!api) {
196
+ return;
197
+ }
198
+
199
+ syncState();
200
+
201
+ api.on("select", syncState);
202
+
203
+ return () => {
204
+ api.off("select", syncState);
205
+ };
206
+ }, [api, syncState]);
207
+
208
+ return (
209
+ <div
210
+ className={cn(
211
+ "flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
212
+ className,
213
+ )}
214
+ {...props}
215
+ >
216
+ {children ?? `${current}/${count}`}
217
+ </div>
218
+ );
219
+ };
220
+
221
+ export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
222
+
223
+ export const InlineCitationCarouselPrev = ({
224
+ className,
225
+ ...props
226
+ }: InlineCitationCarouselPrevProps) => {
227
+ const { t } = useLocale();
228
+ const api = useCarouselApi();
229
+
230
+ const handleClick = useCallback(() => {
231
+ if (api) {
232
+ api.scrollPrev();
233
+ }
234
+ }, [api]);
235
+
236
+ return (
237
+ <button
238
+ aria-label={t("previous")}
239
+ className={cn("shrink-0", className)}
240
+ onClick={handleClick}
241
+ type="button"
242
+ {...props}
243
+ >
244
+ <ArrowLeftIcon className="size-4 text-muted-foreground" data-rtl-flip />
245
+ </button>
246
+ );
247
+ };
248
+
249
+ export type InlineCitationCarouselNextProps = ComponentProps<"button">;
250
+
251
+ export const InlineCitationCarouselNext = ({
252
+ className,
253
+ ...props
254
+ }: InlineCitationCarouselNextProps) => {
255
+ const { t } = useLocale();
256
+ const api = useCarouselApi();
257
+
258
+ const handleClick = useCallback(() => {
259
+ if (api) {
260
+ api.scrollNext();
261
+ }
262
+ }, [api]);
263
+
264
+ return (
265
+ <button
266
+ aria-label={t("next")}
267
+ className={cn("shrink-0", className)}
268
+ onClick={handleClick}
269
+ type="button"
270
+ {...props}
271
+ >
272
+ <ArrowRightIcon className="size-4 text-muted-foreground" data-rtl-flip />
273
+ </button>
274
+ );
275
+ };
276
+
277
+ export type InlineCitationSourceProps = ComponentProps<"div"> & {
278
+ title?: string;
279
+ url?: string;
280
+ description?: string;
281
+ };
282
+
283
+ export const InlineCitationSource = ({
284
+ title,
285
+ url,
286
+ description,
287
+ className,
288
+ children,
289
+ ...props
290
+ }: InlineCitationSourceProps) => (
291
+ <div className={cn("space-y-1", className)} {...props}>
292
+ {title && <h4 className="truncate font-medium text-sm leading-tight">{title}</h4>}
293
+ {url && <p className="truncate break-all text-muted-foreground text-xs">{url}</p>}
294
+ {description && (
295
+ <p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">{description}</p>
296
+ )}
297
+ {children}
298
+ </div>
299
+ );
300
+
301
+ export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
302
+
303
+ export const InlineCitationQuote = ({
304
+ children,
305
+ className,
306
+ ...props
307
+ }: InlineCitationQuoteProps) => (
308
+ <blockquote
309
+ className={cn("border-muted border-s-2 ps-3 text-muted-foreground text-sm italic", className)}
310
+ {...props}
311
+ >
312
+ {children}
313
+ </blockquote>
314
+ );
@@ -0,0 +1,165 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import { Button } from "@elabs-ai/components-ui";
5
+ import { InteractiveTerminal, type InteractiveTerminalHandle } from "./interactive-terminal";
6
+
7
+ const meta = {
8
+ title: "AI/InteractiveTerminal",
9
+ component: InteractiveTerminal,
10
+ tags: ["autodocs"],
11
+ parameters: {
12
+ layout: "padded",
13
+ docs: {
14
+ description: {
15
+ component:
16
+ "A token-themed **xterm.js** terminal — a real interactive PTY surface (ANSI " +
17
+ "output, typed input incl. paste, resize). `Terminal` stays the presentational " +
18
+ "read-only log; this is the wrapper for an actual running process. The " +
19
+ "PTY/process is consumer-owned (D5) — this component only renders ANSI output " +
20
+ "and emits `onData`/`onResize`; it never spawns anything. Press **Escape** or " +
21
+ "**Tab**/**Shift+Tab** at any time to move focus out of the terminal — there is " +
22
+ "no keyboard trap.",
23
+ },
24
+ },
25
+ },
26
+ } satisfies Meta<typeof InteractiveTerminal>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ // Exercises all 8 base ANSI colors + their bright variants, so a cross-theme
32
+ // audit can confirm every swatch stays legible in light/dark.
33
+ const ANSI_PALETTE =
34
+ "\x1b[30mblack\x1b[0m \x1b[31mred\x1b[0m \x1b[32mgreen\x1b[0m \x1b[33myellow\x1b[0m " +
35
+ "\x1b[34mblue\x1b[0m \x1b[35mmagenta\x1b[0m \x1b[36mcyan\x1b[0m \x1b[37mwhite\x1b[0m\r\n" +
36
+ "\x1b[1;30mblack\x1b[0m \x1b[1;31mred\x1b[0m \x1b[1;32mgreen\x1b[0m \x1b[1;33myellow\x1b[0m " +
37
+ "\x1b[1;34mblue\x1b[0m \x1b[1;35mmagenta\x1b[0m \x1b[1;36mcyan\x1b[0m \x1b[1;37mwhite\x1b[0m " +
38
+ "(bright)\r\n";
39
+
40
+ async function waitForXtermTextarea(root: HTMLElement): Promise<HTMLTextAreaElement> {
41
+ let textarea: HTMLTextAreaElement | null = null;
42
+ await waitFor(
43
+ () => {
44
+ textarea = root.querySelector("textarea");
45
+ if (!textarea) throw new Error("xterm textarea not mounted yet");
46
+ },
47
+ { timeout: 10000 },
48
+ );
49
+ return textarea!;
50
+ }
51
+
52
+ /**
53
+ * A minimal fake "shell": echoes typed characters back and, on Enter, prints a
54
+ * canned response — standing in for the real PTY a consumer (e.g. the
55
+ * agent seam in a consuming workbench app) would drive via `onData`/`write()`.
56
+ */
57
+ function EchoDemo() {
58
+ const ref = useRef<InteractiveTerminalHandle>(null);
59
+ const lineRef = useRef("");
60
+ const lastCommandRef = useRef<HTMLParagraphElement>(null);
61
+
62
+ useEffect(() => {
63
+ ref.current?.write(ANSI_PALETTE);
64
+ ref.current?.write("Type a command and press Enter (try “hello”).\r\n$ ");
65
+ }, []);
66
+
67
+ const handleData = (data: string) => {
68
+ if (data === "\r") {
69
+ const command = lineRef.current;
70
+ lineRef.current = "";
71
+ ref.current?.write("\r\n");
72
+ if (command.trim().length > 0) {
73
+ ref.current?.write(`\x1b[32m✓\x1b[0m ran: ${command}\r\n`);
74
+ if (lastCommandRef.current) {
75
+ lastCommandRef.current.textContent = `Last command: “${command}”`;
76
+ }
77
+ }
78
+ ref.current?.write("$ ");
79
+ return;
80
+ }
81
+ if (data === "\x7f") {
82
+ if (lineRef.current.length > 0) {
83
+ lineRef.current = lineRef.current.slice(0, -1);
84
+ ref.current?.write("\b \b");
85
+ }
86
+ return;
87
+ }
88
+ lineRef.current += data;
89
+ ref.current?.write(data);
90
+ };
91
+
92
+ return (
93
+ <div className="flex h-[360px] w-full flex-col gap-2">
94
+ <div className="min-h-0 flex-1">
95
+ <InteractiveTerminal
96
+ ref={ref}
97
+ aria-label="Demo interactive shell"
98
+ className="h-full w-full"
99
+ onData={handleData}
100
+ />
101
+ </div>
102
+ <p ref={lastCommandRef} className="text-caption text-muted-foreground" data-testid="status">
103
+ No command run yet.
104
+ </p>
105
+ <Button size="sm" variant="outline" className="self-start">
106
+ Next focusable control
107
+ </Button>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ export const Default: Story = {
113
+ render: () => <EchoDemo />,
114
+ play: async ({ canvasElement }) => {
115
+ const canvas = within(canvasElement);
116
+ const textarea = await waitForXtermTextarea(canvasElement);
117
+
118
+ // Type a command and press Enter — proves onData receives keystrokes and
119
+ // that write() renders the echoed ANSI response.
120
+ textarea.focus();
121
+ await userEvent.keyboard("hello{enter}");
122
+ await expect(canvas.getByTestId("status")).toHaveTextContent("Last command: “hello”");
123
+
124
+ // No keyboard trap (#285): Tab must move focus OFF the terminal onto the
125
+ // next control, not be swallowed as terminal input.
126
+ await userEvent.tab();
127
+ await expect(canvas.getByRole("button", { name: /next focusable control/i })).toHaveFocus();
128
+ },
129
+ };
130
+
131
+ /**
132
+ * `readOnly` degrades the terminal to the existing log use case: `write()`
133
+ * still renders ANSI output, but stdin is disabled and no `onData` fires.
134
+ */
135
+ export const ReadOnly: Story = {
136
+ name: "Read-only (log)",
137
+ parameters: {
138
+ docs: {
139
+ description: {
140
+ story:
141
+ "`readOnly` disables stdin (xterm's `disableStdin`) — `write()` still renders, " +
142
+ "so this covers the same append-only log use case as `Terminal`, with real ANSI " +
143
+ "cursor/color handling.",
144
+ },
145
+ },
146
+ },
147
+ render: function ReadOnlyStory() {
148
+ const ref = useRef<InteractiveTerminalHandle>(null);
149
+
150
+ useEffect(() => {
151
+ ref.current?.write("$ pnpm build\r\n");
152
+ ref.current?.write(ANSI_PALETTE);
153
+ ref.current?.write("\x1b[32m✓\x1b[0m Build complete in 4.2s\r\n");
154
+ }, []);
155
+
156
+ return (
157
+ <div className="h-[280px] w-full">
158
+ <InteractiveTerminal ref={ref} aria-label="Build log" className="h-full w-full" readOnly />
159
+ </div>
160
+ );
161
+ },
162
+ play: async ({ canvasElement }) => {
163
+ await waitForXtermTextarea(canvasElement);
164
+ },
165
+ };