@elabs-ai/components-ai 4.0.0 → 4.1.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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- 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
|
-
);
|
package/src/model-selector.tsx
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Command,
|
|
3
|
-
CommandDialog,
|
|
4
|
-
CommandEmpty,
|
|
5
|
-
CommandGroup,
|
|
6
|
-
CommandInput,
|
|
7
|
-
CommandItem,
|
|
8
|
-
CommandList,
|
|
9
|
-
CommandSeparator,
|
|
10
|
-
CommandShortcut,
|
|
11
|
-
} from "@elabs-ai/components-ui";
|
|
12
|
-
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@elabs-ai/components-ui";
|
|
13
|
-
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
14
|
-
import { BotIcon } from "lucide-react";
|
|
15
|
-
import type { ComponentProps, ReactNode } from "react";
|
|
16
|
-
import { useState } from "react";
|
|
17
|
-
|
|
18
|
-
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
|
19
|
-
|
|
20
|
-
export const ModelSelector = (props: ModelSelectorProps) => <Dialog {...props} />;
|
|
21
|
-
|
|
22
|
-
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
|
23
|
-
|
|
24
|
-
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
|
25
|
-
<DialogTrigger {...props} />
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
|
29
|
-
title?: ReactNode;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export const ModelSelectorContent = ({
|
|
33
|
-
className,
|
|
34
|
-
children,
|
|
35
|
-
title = "Model Selector",
|
|
36
|
-
...props
|
|
37
|
-
}: ModelSelectorContentProps) => (
|
|
38
|
-
<DialogContent
|
|
39
|
-
aria-describedby={undefined}
|
|
40
|
-
className={cn("outline! border-none! p-0 outline-border! outline-solid!", className)}
|
|
41
|
-
{...props}
|
|
42
|
-
>
|
|
43
|
-
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
44
|
-
<Command className="**:data-[slot=command-input-wrapper]:h-auto">{children}</Command>
|
|
45
|
-
</DialogContent>
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
|
49
|
-
|
|
50
|
-
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
|
51
|
-
<CommandDialog {...props} />
|
|
52
|
-
);
|
|
53
|
-
|
|
54
|
-
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
|
55
|
-
|
|
56
|
-
export const ModelSelectorInput = ({ className, ...props }: ModelSelectorInputProps) => (
|
|
57
|
-
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
|
61
|
-
|
|
62
|
-
export const ModelSelectorList = (props: ModelSelectorListProps) => <CommandList {...props} />;
|
|
63
|
-
|
|
64
|
-
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
|
65
|
-
|
|
66
|
-
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => <CommandEmpty {...props} />;
|
|
67
|
-
|
|
68
|
-
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
|
69
|
-
|
|
70
|
-
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => <CommandGroup {...props} />;
|
|
71
|
-
|
|
72
|
-
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
|
73
|
-
|
|
74
|
-
export const ModelSelectorItem = (props: ModelSelectorItemProps) => <CommandItem {...props} />;
|
|
75
|
-
|
|
76
|
-
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
|
77
|
-
|
|
78
|
-
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
|
79
|
-
<CommandShortcut {...props} />
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
export type ModelSelectorSeparatorProps = ComponentProps<typeof CommandSeparator>;
|
|
83
|
-
|
|
84
|
-
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
|
85
|
-
<CommandSeparator {...props} />
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Where provider logos are fetched from by default.
|
|
90
|
-
*
|
|
91
|
-
* This is a REMOTE origin: a deployment with a restrictive `img-src` blocks it.
|
|
92
|
-
* Self-host the SVGs and pass `src` (or set a `fallback`) — see
|
|
93
|
-
* `docs/CSP-AND-NETWORK.md`.
|
|
94
|
-
*/
|
|
95
|
-
export const MODEL_SELECTOR_LOGO_BASE_URL = "https://models.dev/logos";
|
|
96
|
-
|
|
97
|
-
export type ModelSelectorLogoProps = Omit<ComponentProps<"img">, "alt"> & {
|
|
98
|
-
/**
|
|
99
|
-
* Override the logo URL — point at a self-hosted or bundled asset when a CSP
|
|
100
|
-
* blocks `models.dev`. Defaults to `${MODEL_SELECTOR_LOGO_BASE_URL}/<provider>.svg`.
|
|
101
|
-
*/
|
|
102
|
-
src?: string;
|
|
103
|
-
/**
|
|
104
|
-
* Rendered when the logo fails to load (blocked, offline, or unknown
|
|
105
|
-
* provider). Defaults to a neutral Lucide glyph, so the row never collapses
|
|
106
|
-
* to a broken image.
|
|
107
|
-
*/
|
|
108
|
-
fallback?: ReactNode;
|
|
109
|
-
provider:
|
|
110
|
-
| "moonshotai-cn"
|
|
111
|
-
| "lucidquery"
|
|
112
|
-
| "moonshotai"
|
|
113
|
-
| "zai-coding-plan"
|
|
114
|
-
| "alibaba"
|
|
115
|
-
| "xai"
|
|
116
|
-
| "vultr"
|
|
117
|
-
| "nvidia"
|
|
118
|
-
| "upstage"
|
|
119
|
-
| "groq"
|
|
120
|
-
| "github-copilot"
|
|
121
|
-
| "mistral"
|
|
122
|
-
| "vercel"
|
|
123
|
-
| "nebius"
|
|
124
|
-
| "deepseek"
|
|
125
|
-
| "alibaba-cn"
|
|
126
|
-
| "google-vertex-anthropic"
|
|
127
|
-
| "venice"
|
|
128
|
-
| "chutes"
|
|
129
|
-
| "cortecs"
|
|
130
|
-
| "github-models"
|
|
131
|
-
| "togetherai"
|
|
132
|
-
| "azure"
|
|
133
|
-
| "baseten"
|
|
134
|
-
| "huggingface"
|
|
135
|
-
| "opencode"
|
|
136
|
-
| "fastrouter"
|
|
137
|
-
| "google"
|
|
138
|
-
| "google-vertex"
|
|
139
|
-
| "cloudflare-workers-ai"
|
|
140
|
-
| "inception"
|
|
141
|
-
| "wandb"
|
|
142
|
-
| "openai"
|
|
143
|
-
| "zhipuai-coding-plan"
|
|
144
|
-
| "perplexity"
|
|
145
|
-
| "openrouter"
|
|
146
|
-
| "zenmux"
|
|
147
|
-
| "v0"
|
|
148
|
-
| "iflowcn"
|
|
149
|
-
| "synthetic"
|
|
150
|
-
| "deepinfra"
|
|
151
|
-
| "zhipuai"
|
|
152
|
-
| "submodel"
|
|
153
|
-
| "zai"
|
|
154
|
-
| "inference"
|
|
155
|
-
| "requesty"
|
|
156
|
-
| "morph"
|
|
157
|
-
| "lmstudio"
|
|
158
|
-
| "anthropic"
|
|
159
|
-
| "aihubmix"
|
|
160
|
-
| "fireworks-ai"
|
|
161
|
-
| "modelscope"
|
|
162
|
-
| "llama"
|
|
163
|
-
| "scaleway"
|
|
164
|
-
| "amazon-bedrock"
|
|
165
|
-
| "cerebras"
|
|
166
|
-
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
|
|
167
|
-
| (string & {});
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
export const ModelSelectorLogo = ({
|
|
171
|
-
provider,
|
|
172
|
-
className,
|
|
173
|
-
src,
|
|
174
|
-
fallback,
|
|
175
|
-
onError,
|
|
176
|
-
...props
|
|
177
|
-
}: ModelSelectorLogoProps) => {
|
|
178
|
-
const [failed, setFailed] = useState(false);
|
|
179
|
-
|
|
180
|
-
// A blocked/missing logo must not leave a broken-image glyph in the row.
|
|
181
|
-
if (failed) {
|
|
182
|
-
return (
|
|
183
|
-
<>
|
|
184
|
-
{fallback ?? (
|
|
185
|
-
<BotIcon aria-label={`${provider} logo`} className={cn("size-3", className)} role="img" />
|
|
186
|
-
)}
|
|
187
|
-
</>
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<img
|
|
193
|
-
{...props}
|
|
194
|
-
alt={`${provider} logo`}
|
|
195
|
-
className={cn("size-3 dark:invert", className)}
|
|
196
|
-
height={12}
|
|
197
|
-
onError={(event) => {
|
|
198
|
-
setFailed(true);
|
|
199
|
-
onError?.(event);
|
|
200
|
-
}}
|
|
201
|
-
// AFTER the spread, so the caller's `src` wins via the destructured
|
|
202
|
-
// default rather than being silently overwritten.
|
|
203
|
-
src={src ?? `${MODEL_SELECTOR_LOGO_BASE_URL}/${provider}.svg`}
|
|
204
|
-
width={12}
|
|
205
|
-
/>
|
|
206
|
-
);
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
|
210
|
-
|
|
211
|
-
export const ModelSelectorLogoGroup = ({ className, ...props }: ModelSelectorLogoGroupProps) => (
|
|
212
|
-
<div
|
|
213
|
-
className={cn(
|
|
214
|
-
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
|
215
|
-
className,
|
|
216
|
-
)}
|
|
217
|
-
{...props}
|
|
218
|
-
/>
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
export type ModelSelectorNameProps = ComponentProps<"span">;
|
|
222
|
-
|
|
223
|
-
export const ModelSelectorName = ({ className, ...props }: ModelSelectorNameProps) => (
|
|
224
|
-
<span className={cn("flex-1 truncate text-start", className)} {...props} />
|
|
225
|
-
);
|