@elabs-ai/components-ai 4.0.0 → 4.2.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/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -1,444 +0,0 @@
1
- "use client";
2
-
3
- /**
4
- * InteractiveTerminal — a token-themed wrapper around xterm.js (issue #285).
5
- *
6
- * `Terminal` (terminal.tsx) is a presentational, READ-ONLY ANSI log (no stdin,
7
- * no cursor, no resize protocol). This is a SEPARATE component for a real
8
- * interactive terminal surface: run a command, see live ANSI output, type into
9
- * the process. The PTY/process itself is consumer-owned (D5) — this component
10
- * only renders and emits input/resize events; it never spawns anything.
11
- *
12
- * Theming: xterm can't read CSS custom properties, so its `ITheme` (background/
13
- * foreground/cursor/selection + the 16 ANSI colors) is derived from semantic
14
- * tokens at runtime via `oklchToHex`/`resolveTokenColor` (`@elabs-ai/components-tokens`, ADR
15
- * 0015) — the same "wrap an engine, theme it from tokens" pattern as
16
- * `@elabs-ai/components-editor`'s Monaco bridge and `@elabs-ai/components-maps`' `useTokenColor`. It
17
- * re-resolves whenever `data-theme` changes (a local MutationObserver, mirroring
18
- * `persona.tsx`'s theme watcher — `@elabs-ai/components-ai` can't import `@elabs-ai/components-editor`'s
19
- * shared `useDataTheme` hook per the one-way package graph).
20
- *
21
- * Bundling: xterm and its stylesheet are reached through a dynamic
22
- * `import("./_interactive-terminal-xterm")` inside the mount effect — the engine
23
- * is never touched during render — so it lands in its own chunk instead of every
24
- * consumer's entry chunk. The types below are `import type` and erase. See ADR
25
- * 0019 and `pnpm heavy-deps:check`.
26
- */
27
-
28
- import { resolveTokenColor } from "@elabs-ai/components-tokens";
29
- import { cn } from "@elabs-ai/components-ui/lib/cn";
30
- import type { FitAddon } from "@xterm/addon-fit";
31
- import type { ITheme, Terminal as XTerm } from "@xterm/xterm";
32
- import {
33
- forwardRef,
34
- useEffect,
35
- useImperativeHandle,
36
- useRef,
37
- useState,
38
- type HTMLAttributes,
39
- } from "react";
40
-
41
- export interface InteractiveTerminalHandle {
42
- /** Process → screen. ANSI passthrough (colors, cursor movement, clears, …). */
43
- write(data: string): void;
44
- /** Clears the viewport + scrollback. */
45
- clear(): void;
46
- /** Re-fits cols/rows to the current container size. */
47
- fit(): void;
48
- /** Focuses the terminal's input surface. */
49
- focus(): void;
50
- }
51
-
52
- export interface InteractiveTerminalProps extends Omit<HTMLAttributes<HTMLDivElement>, "onResize"> {
53
- /** Keystrokes + paste → process. The PTY/process stays consumer-owned. */
54
- onData?: (data: string) => void;
55
- /** Fires whenever the container resizes and cols/rows are refit. */
56
- onResize?: (size: { cols: number; rows: number }) => void;
57
- /** Degrades to a read-only log: writes still render, stdin is disabled. */
58
- readOnly?: boolean;
59
- /** Terminal font size in px. Defaults to `13` (matches `CodeEditor`). */
60
- fontSize?: number;
61
- /** Accessible name for the terminal's real focusable surface. Required. */
62
- "aria-label": string;
63
- }
64
-
65
- // --- Runtime color helpers -------------------------------------------------
66
- // `resolveTokenColor` (@elabs-ai/components-tokens, ADR 0015) is the shared "read a semantic
67
- // token off an element, oklch → hex" resolver — the same one `@elabs-ai/components-maps`'
68
- // `useTokenColor` wraps. `withAlpha`/`lighten` below are the same "math on the
69
- // resolved hex" idiom as `monaco-theme-bridge.ts` (@elabs-ai/components-editor) — never a
70
- // hardcoded literal color.
71
-
72
- /** Mix `alpha` (0..1) into a `#rrggbb` color, returning `#rrggbbaa`. */
73
- function withAlpha(hex: string, alpha: number): string {
74
- const base = hex.slice(0, 7);
75
- const byte = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
76
- .toString(16)
77
- .padStart(2, "0");
78
- return `${base}${byte}`;
79
- }
80
-
81
- /** Mix a `#rrggbb` color toward `target` (`#rrggbb`) by `amount` (0..1). */
82
- function mixToward(hex: string, target: string, amount: number): string {
83
- const base = hex.slice(0, 7);
84
- const channel = (source: string, i: number) =>
85
- parseInt(source.slice(1 + i * 2, 3 + i * 2), 16) || 0;
86
- const toHex = (n: number) =>
87
- Math.min(255, Math.max(0, Math.round(n)))
88
- .toString(16)
89
- .padStart(2, "0");
90
- const mix = (i: number) => {
91
- const from = channel(base, i);
92
- return from + (channel(target, i) - from) * amount;
93
- };
94
- return `#${toHex(mix(0))}${toHex(mix(1))}${toHex(mix(2))}`;
95
- }
96
-
97
- /** WCAG 2.1 relative luminance of a `#rrggbb`. */
98
- function relativeLuminance(hex: string): number {
99
- const channel = (i: number) => {
100
- const c = (parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) || 0) / 255;
101
- return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
102
- };
103
- return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
104
- }
105
-
106
- /** WCAG contrast ratio between two `#rrggbb` colors. */
107
- function contrastRatio(a: string, b: string): number {
108
- const la = relativeLuminance(a);
109
- const lb = relativeLuminance(b);
110
- return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
111
- }
112
-
113
- /** The pole a color must move toward to gain contrast against `bg`. */
114
- const awayPole = (bg: string) => (relativeLuminance(bg) > 0.5 ? "#000000" : "#ffffff");
115
-
116
- /**
117
- * Push `hex` AWAY from `bg` by `amount` — darker on a light ground, lighter on
118
- * a dark one. This is how a "bright" ANSI sibling is derived for a hue with no
119
- * dedicated lighter/darker token: `lighten()` used to hardcode "toward white",
120
- * which on a light theme moved the swatch TOWARD the background and made the
121
- * bright variant the least legible colour on screen (#386 — in `light`
122
- * brightMagenta bottomed out at 2.91:1, and in the `:root` base at 2.39:1).
123
- */
124
- function awayFromBackground(hex: string, bg: string, amount: number): string {
125
- return mixToward(hex, awayPole(bg), amount);
126
- }
127
-
128
- /**
129
- * The readable-ink floor (#386). xterm paints every ANSI colour code as real
130
- * TEXT on the terminal background, so each one owes WCAG AA (4.5:1) — but the
131
- * semantic tokens the mapping reaches for are not all text rungs: `--chart-2`/
132
- * `--chart-4`/`--border-strong` and the `--success`/`--info`/… FILL rungs are
133
- * MARK colours, guaranteed only ≥3:1 (.claude/rules/styling-and-tokens.md).
134
- * Rather than re-pick tokens per theme — which would silently rot the next time
135
- * a token is revalued — every ink is clamped here against the background it is
136
- * actually painted on: an already-AA colour passes through byte-identical, a
137
- * short one is mixed toward the far pole until it clears. Applies to every slot
138
- * EXCEPT `black`, which is the background RUNG by ANSI convention (see the
139
- * mapping notes below), not ink chosen for legibility.
140
- */
141
- function readableInk(hex: string, bg: string, ratio = 4.5): string {
142
- const base = hex.slice(0, 7);
143
- if (contrastRatio(base, bg) >= ratio) return base;
144
- const pole = awayPole(bg);
145
- const STEPS = 40;
146
- for (let step = 1; step <= STEPS; step += 1) {
147
- const candidate = mixToward(base, pole, step / STEPS);
148
- if (contrastRatio(candidate, bg) >= ratio) return candidate;
149
- }
150
- return pole;
151
- }
152
-
153
- /**
154
- * Builds xterm's `ITheme` from the active theme's semantic tokens, resolved
155
- * off `rootEl` (defaults to `<html>`, where `data-theme` lives).
156
- *
157
- * ANSI mapping (documented so the intent is auditable, not guessed at):
158
- * - background/foreground → `--card`/`--foreground` (the terminal reads as a
159
- * raised panel, matching the existing `Terminal` log's bordered-box look).
160
- * - cursor → `--primary`; cursorAccent → the background, so glyphs stay
161
- * legible under a solid block cursor.
162
- * - selectionBackground → a low-alpha `--primary` wash.
163
- * - red/green/yellow/blue → the AA-tuned `-text` variants (`--destructive-text`,
164
- * `--success-text`, `--warning-text`, `--info-text`) — these are the ones
165
- * tuned to read as TEXT on a surface, which is exactly what ANSI color codes
166
- * paint. `bright*` variants use the more saturated fill tokens
167
- * (`--destructive`, `--success`, `--warning`, `--info`).
168
- * - magenta/cyan → `--chart-4`/`--chart-2` (no dedicated fill/text pair, so the
169
- * `bright*` siblings are the same hue pushed AWAY from the background).
170
- * - black/white are `background`/`foreground` RUNGS, not the tokens
171
- * themselves: `black` = the terminal's own background (the common "ANSI
172
- * black" convention — invisible ink), `brightBlack` = `--border-strong`
173
- * (a dim ink for comments/hashes), `white` = `--muted-foreground` (a dimmer
174
- * ink), `brightWhite` = `--foreground` (full ink).
175
- *
176
- * EVERY slot above except `black` then passes through `readableInk()` — the AA
177
- * floor (#386). The mapping picks the token that carries the right MEANING; the
178
- * floor guarantees the resulting ink is legible on this terminal's actual
179
- * background. Several of these tokens are mark/fill rungs (≥3:1 only), so
180
- * without the floor EVERY palette this repo ships had sub-AA ANSI slots —
181
- * measured from `themes.css`: `:root` 7 (worst 2.39:1), `light` 4 (worst
182
- * 2.91:1), `dark` 1 (3.16:1). Re-derived on every
183
- * run by `interactive-terminal.test.tsx`, which parses those palettes out of
184
- * `themes.css` rather than hard-coding them. Keep new slots inside `ink(...)`;
185
- * a raw token assigned straight to an ANSI slot is the bug.
186
- */
187
- export function buildInteractiveTerminalTheme(rootEl?: Element | null): ITheme {
188
- const el = rootEl ?? (typeof document !== "undefined" ? document.documentElement : null);
189
- const read = (name: string, fallback: string) => resolveTokenColor(name, { el, fallback });
190
-
191
- const background = read("--card", "#ffffff");
192
- const foreground = read("--foreground", "#111111");
193
- const primary = read("--primary", foreground);
194
- const mutedForeground = read("--muted-foreground", foreground);
195
- const borderStrong = read("--border-strong", mutedForeground);
196
-
197
- const destructiveText = read("--destructive-text", "#b91c1c");
198
- const destructive = read("--destructive", destructiveText);
199
- const successText = read("--success-text", "#15803d");
200
- const success = read("--success", successText);
201
- const warningText = read("--warning-text", "#a16207");
202
- const warning = read("--warning", warningText);
203
- const infoText = read("--info-text", primary);
204
- const info = read("--info", primary);
205
- const chart2 = read("--chart-2", infoText);
206
- const chart4 = read("--chart-4", destructiveText);
207
-
208
- // Every ANSI slot below is INK — clamp it to AA against this terminal's own
209
- // background (#386). `black` is the one exception: it is the background rung
210
- // by ANSI convention, not ink.
211
- const ink = (hex: string) => readableInk(hex, background);
212
-
213
- return {
214
- background,
215
- foreground,
216
- cursor: primary,
217
- cursorAccent: background,
218
- selectionBackground: withAlpha(primary, 0.28),
219
-
220
- black: background,
221
- red: ink(destructiveText),
222
- green: ink(successText),
223
- yellow: ink(warningText),
224
- blue: ink(infoText),
225
- magenta: ink(chart4),
226
- cyan: ink(chart2),
227
- white: ink(mutedForeground),
228
-
229
- brightBlack: ink(borderStrong),
230
- brightRed: ink(destructive),
231
- brightGreen: ink(success),
232
- brightYellow: ink(warning),
233
- brightBlue: ink(info),
234
- brightMagenta: ink(awayFromBackground(chart4, background, 0.3)),
235
- brightCyan: ink(awayFromBackground(chart2, background, 0.3)),
236
- brightWhite: ink(foreground),
237
- };
238
- }
239
-
240
- /** Reads the theme's mono font stack (the `--font-mono` seam — a theme
241
- * overrides it to IBM Plex Mono) so the terminal's glyphs track it too. */
242
- function readTerminalFontFamily(rootEl?: Element | null): string {
243
- const el = rootEl ?? (typeof document !== "undefined" ? document.documentElement : null);
244
- if (!el || typeof getComputedStyle === "undefined") return "monospace";
245
- const raw = getComputedStyle(el).getPropertyValue("--font-mono").trim();
246
- return raw || "monospace";
247
- }
248
-
249
- const DEFAULT_FONT_SIZE = 13;
250
-
251
- /**
252
- * A token-themed, PTY-ready terminal emulator wrapped as a brand-ui component.
253
- * Wraps xterm.js + `FitAddon`; the process itself is consumer-owned — this
254
- * component only renders ANSI output and emits `onData`/`onResize`.
255
- *
256
- * Keyboard: the terminal's real focusable surface is xterm's own input
257
- * textarea (reachable by Tab like any control). Tab/Shift-Tab and Escape are
258
- * explicitly let through (`attachCustomKeyEventHandler`) so focus can always
259
- * leave the terminal — there is no keyboard trap.
260
- */
261
- export const InteractiveTerminal = forwardRef<InteractiveTerminalHandle, InteractiveTerminalProps>(
262
- function InteractiveTerminal(
263
- {
264
- onData,
265
- onResize,
266
- readOnly = false,
267
- fontSize = DEFAULT_FONT_SIZE,
268
- className,
269
- "aria-label": ariaLabel,
270
- ...props
271
- },
272
- ref,
273
- ) {
274
- const containerRef = useRef<HTMLDivElement>(null);
275
- const fitAddonRef = useRef<FitAddon | null>(null);
276
- const [term, setTerm] = useState<XTerm | null>(null);
277
- const [themeRevision, setThemeRevision] = useState(0);
278
-
279
- const onDataRef = useRef(onData);
280
- onDataRef.current = onData;
281
- const onResizeRef = useRef(onResize);
282
- onResizeRef.current = onResize;
283
-
284
- // Output written before the engine chunk resolves — a consumer's mount
285
- // effect is the common case (`useEffect(() => ref.current?.write(banner))`).
286
- // It is buffered here and replayed the moment the terminal opens, so the
287
- // lazy import stays invisible to the imperative handle's contract.
288
- const pendingWritesRef = useRef<string[]>([]);
289
-
290
- useImperativeHandle<InteractiveTerminalHandle, InteractiveTerminalHandle>(
291
- ref,
292
- () => ({
293
- write: (data: string) => {
294
- if (term) {
295
- term.write(data);
296
- return;
297
- }
298
- pendingWritesRef.current.push(data);
299
- },
300
- clear: () => {
301
- pendingWritesRef.current.length = 0;
302
- term?.clear();
303
- },
304
- fit: () => fitAddonRef.current?.fit(),
305
- focus: () => term?.focus(),
306
- }),
307
- [term],
308
- );
309
-
310
- // Mount once: fetch the engine chunk, create the xterm instance + FitAddon,
311
- // open into the container, wire the resize observer + no-keyboard-trap
312
- // handler. The engine arrives asynchronously (it is a lazy chunk), so the
313
- // cleanup below has to cope with unmounting mid-flight.
314
- useEffect(() => {
315
- const container = containerRef.current;
316
- if (!container) return;
317
-
318
- let disposed = false;
319
- let instance: XTerm | undefined;
320
- let fitAddon: FitAddon | undefined;
321
- let resizeObserver: ResizeObserver | undefined;
322
-
323
- void import("./_interactive-terminal-xterm").then((engine) => {
324
- if (disposed) return;
325
-
326
- instance = new engine.XTerm({
327
- fontSize,
328
- fontFamily: readTerminalFontFamily(),
329
- convertEol: true,
330
- cursorBlink: true,
331
- scrollback: 2000,
332
- disableStdin: readOnly,
333
- theme: buildInteractiveTerminalTheme(),
334
- });
335
- fitAddon = new engine.FitAddon();
336
- instance.loadAddon(fitAddon);
337
- instance.open(container);
338
- if (ariaLabel && instance.textarea) {
339
- instance.textarea.setAttribute("aria-label", ariaLabel);
340
- }
341
-
342
- // Replay anything the consumer wrote while the chunk was in flight.
343
- for (const chunk of pendingWritesRef.current) instance.write(chunk);
344
- pendingWritesRef.current = [];
345
-
346
- // No keyboard trap (#285 acceptance criterion): let Tab/Shift-Tab and
347
- // Escape bubble to the browser's normal focus handling instead of being
348
- // consumed as terminal input, so focus can always leave the terminal.
349
- instance.attachCustomKeyEventHandler((event) => {
350
- if (event.key === "Tab" || event.key === "Escape") return false;
351
- return true;
352
- });
353
-
354
- fitAddonRef.current = fitAddon;
355
- setTerm(instance);
356
-
357
- const observer = new ResizeObserver(() => {
358
- try {
359
- fitAddon?.fit();
360
- } catch {
361
- // Container not laid out yet (e.g. display:none) — ignore, the next
362
- // resize observation will retry.
363
- }
364
- });
365
- observer.observe(container);
366
- resizeObserver = observer;
367
- });
368
-
369
- return () => {
370
- disposed = true;
371
- resizeObserver?.disconnect();
372
- fitAddon?.dispose();
373
- instance?.dispose();
374
- fitAddonRef.current = null;
375
- setTerm(null);
376
- };
377
- // eslint-disable-next-line react-hooks/exhaustive-deps
378
- }, []);
379
-
380
- // Keystrokes + paste → consumer, only while interactive.
381
- useEffect(() => {
382
- if (!term || readOnly) return;
383
- const sub = term.onData((data) => onDataRef.current?.(data));
384
- return () => sub.dispose();
385
- }, [term, readOnly]);
386
-
387
- // Container resize → refit → report the new cols/rows.
388
- useEffect(() => {
389
- if (!term) return;
390
- const sub = term.onResize((size) => onResizeRef.current?.(size));
391
- return () => sub.dispose();
392
- }, [term]);
393
-
394
- // `readOnly` toggles xterm's own stdin gate at runtime too (not just at
395
- // construction), so it can be flipped on a live instance.
396
- useEffect(() => {
397
- if (!term) return;
398
- term.options.disableStdin = readOnly;
399
- }, [term, readOnly]);
400
-
401
- useEffect(() => {
402
- if (!term || fontSize === term.options.fontSize) return;
403
- term.options.fontSize = fontSize;
404
- fitAddonRef.current?.fit();
405
- }, [term, fontSize]);
406
-
407
- useEffect(() => {
408
- if (!term || !term.textarea || !ariaLabel) return;
409
- term.textarea.setAttribute("aria-label", ariaLabel);
410
- }, [term, ariaLabel]);
411
-
412
- // Track `data-theme` (ThemeProvider or Storybook's theme decorator both
413
- // just set the attribute) and re-apply the derived xterm theme + font.
414
- useEffect(() => {
415
- if (typeof document === "undefined") return;
416
- const observer = new MutationObserver(() => setThemeRevision((r) => r + 1));
417
- observer.observe(document.documentElement, {
418
- attributes: true,
419
- attributeFilter: ["data-theme"],
420
- });
421
- return () => observer.disconnect();
422
- }, []);
423
-
424
- useEffect(() => {
425
- if (!term) return;
426
- term.options.theme = buildInteractiveTerminalTheme();
427
- term.options.fontFamily = readTerminalFontFamily();
428
- fitAddonRef.current?.fit();
429
- // `themeRevision` is the trigger; the values themselves are re-read live.
430
- }, [term, themeRevision]);
431
-
432
- return (
433
- <div
434
- ref={containerRef}
435
- className={cn(
436
- "overflow-hidden rounded-lg border bg-card p-2 outline-none",
437
- "focus-within:ring-2 focus-within:ring-ring",
438
- className,
439
- )}
440
- {...props}
441
- />
442
- );
443
- },
444
- );
@@ -1,55 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { fireEvent, render, screen } from "@testing-library/react";
3
-
4
- import { MODEL_SELECTOR_LOGO_BASE_URL, ModelSelectorLogo } from "./model-selector";
5
-
6
- describe("ModelSelectorLogo — remote origin escape hatches", () => {
7
- it("defaults to the models.dev logo for the provider", () => {
8
- render(<ModelSelectorLogo provider="anthropic" />);
9
- expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
10
- "src",
11
- `${MODEL_SELECTOR_LOGO_BASE_URL}/anthropic.svg`,
12
- );
13
- });
14
-
15
- it("lets a caller override src for a self-hosted asset", () => {
16
- // The old props type did `Omit<…, "src">` AND spread props before `src=`, so
17
- // a self-hosting consumer had no way to win. Both are fixed.
18
- render(<ModelSelectorLogo provider="anthropic" src="/local/anthropic.svg" />);
19
- expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
20
- "src",
21
- "/local/anthropic.svg",
22
- );
23
- });
24
-
25
- it("renders a neutral glyph instead of a broken image when the load fails", () => {
26
- render(<ModelSelectorLogo provider="anthropic" />);
27
- fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
28
-
29
- const fallback = screen.getByRole("img", { name: "anthropic logo" });
30
- expect(fallback.tagName.toLowerCase()).toBe("svg");
31
- expect(fallback).not.toHaveAttribute("src");
32
- });
33
-
34
- it("renders a caller-supplied fallback when provided", () => {
35
- render(<ModelSelectorLogo provider="anthropic" fallback={<span data-testid="fb">AI</span>} />);
36
- fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
37
- expect(screen.getByTestId("fb")).toBeInTheDocument();
38
- });
39
-
40
- it("still calls a caller's onError", () => {
41
- const onError = vi.fn();
42
- render(<ModelSelectorLogo provider="anthropic" onError={onError} />);
43
- fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
44
- expect(onError).toHaveBeenCalledTimes(1);
45
- });
46
-
47
- it("keeps an accessible name in both states", () => {
48
- const { rerender } = render(<ModelSelectorLogo provider="openai" />);
49
- expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
50
-
51
- fireEvent.error(screen.getByRole("img", { name: "openai logo" }));
52
- rerender(<ModelSelectorLogo provider="openai" />);
53
- expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
54
- });
55
- });