@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,165 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,448 +0,0 @@
|
|
|
1
|
-
import { createRef, type ReactElement } from "react";
|
|
2
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
import { oklchToHex } from "@elabs-ai/components-tokens";
|
|
8
|
-
|
|
9
|
-
// xterm.js needs a real canvas + layout to render, so it cannot mount in
|
|
10
|
-
// jsdom — mock the engine and assert the wrapper's lifecycle + theming
|
|
11
|
-
// contract. Real rendering + a11y come from the Storybook interaction tests.
|
|
12
|
-
// Mirrors the Monaco mocking pattern in @elabs-ai/components-editor's code-editor.test.tsx.
|
|
13
|
-
const h = vi.hoisted(() => {
|
|
14
|
-
const state = {
|
|
15
|
-
dataHandler: undefined as undefined | ((data: string) => void),
|
|
16
|
-
resizeHandler: undefined as undefined | ((size: { cols: number; rows: number }) => void),
|
|
17
|
-
};
|
|
18
|
-
const textarea = { setAttribute: vi.fn(), getAttribute: vi.fn() };
|
|
19
|
-
|
|
20
|
-
function makeInstance() {
|
|
21
|
-
return {
|
|
22
|
-
options: { fontSize: 13, disableStdin: false } as Record<string, unknown>,
|
|
23
|
-
textarea,
|
|
24
|
-
loadAddon: vi.fn(),
|
|
25
|
-
open: vi.fn(),
|
|
26
|
-
write: vi.fn(),
|
|
27
|
-
clear: vi.fn(),
|
|
28
|
-
focus: vi.fn(),
|
|
29
|
-
dispose: vi.fn(),
|
|
30
|
-
attachCustomKeyEventHandler: vi.fn(),
|
|
31
|
-
onData: vi.fn((cb: (data: string) => void) => {
|
|
32
|
-
state.dataHandler = cb;
|
|
33
|
-
return { dispose: vi.fn() };
|
|
34
|
-
}),
|
|
35
|
-
onResize: vi.fn((cb: (size: { cols: number; rows: number }) => void) => {
|
|
36
|
-
state.resizeHandler = cb;
|
|
37
|
-
return { dispose: vi.fn() };
|
|
38
|
-
}),
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const instances: Array<ReturnType<typeof makeInstance>> = [];
|
|
43
|
-
const Terminal = vi.fn(() => {
|
|
44
|
-
const instance = makeInstance();
|
|
45
|
-
instances.push(instance);
|
|
46
|
-
return instance;
|
|
47
|
-
});
|
|
48
|
-
const FitAddon = vi.fn(() => ({ fit: vi.fn(), dispose: vi.fn() }));
|
|
49
|
-
|
|
50
|
-
return { state, textarea, instances, Terminal, FitAddon };
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
vi.mock("@xterm/xterm", () => ({
|
|
54
|
-
Terminal: h.Terminal,
|
|
55
|
-
}));
|
|
56
|
-
vi.mock("@xterm/addon-fit", () => ({
|
|
57
|
-
FitAddon: h.FitAddon,
|
|
58
|
-
}));
|
|
59
|
-
// The real stylesheet import is a side-effect-only CSS import (already a
|
|
60
|
-
// no-op under this package's `css: false` vitest config); mock it explicitly
|
|
61
|
-
// so the module graph doesn't need to touch the filesystem in this test.
|
|
62
|
-
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
|
63
|
-
|
|
64
|
-
import {
|
|
65
|
-
buildInteractiveTerminalTheme,
|
|
66
|
-
InteractiveTerminal,
|
|
67
|
-
type InteractiveTerminalHandle,
|
|
68
|
-
} from "./interactive-terminal";
|
|
69
|
-
|
|
70
|
-
beforeEach(() => {
|
|
71
|
-
h.state.dataHandler = undefined;
|
|
72
|
-
h.state.resizeHandler = undefined;
|
|
73
|
-
h.instances.length = 0;
|
|
74
|
-
vi.clearAllMocks();
|
|
75
|
-
});
|
|
76
|
-
afterEach(cleanup);
|
|
77
|
-
|
|
78
|
-
/** Lets the pending dynamic `import()` + its `.then` settle inside `act`. */
|
|
79
|
-
const flush = () => act(async () => void (await new Promise((resolve) => setTimeout(resolve, 0))));
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* xterm is reached through a dynamic `import()` inside the mount effect (ADR
|
|
83
|
-
* 0019 — the engine must not sit in every consumer's entry chunk), so nothing
|
|
84
|
-
* is constructed synchronously with `render()`. Every lifecycle assertion waits
|
|
85
|
-
* for the engine chunk to resolve first.
|
|
86
|
-
*/
|
|
87
|
-
async function renderTerminal(ui: ReactElement) {
|
|
88
|
-
const result = render(ui);
|
|
89
|
-
await waitFor(() => expect(h.instances.length).toBeGreaterThan(0));
|
|
90
|
-
return result;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
describe("InteractiveTerminal", () => {
|
|
94
|
-
it("creates an xterm instance and opens it into the container on mount", async () => {
|
|
95
|
-
await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
|
|
96
|
-
expect(h.Terminal).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(h.instances[0]?.open).toHaveBeenCalledTimes(1);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("does not touch the engine during render — it is a lazy chunk (#313)", async () => {
|
|
101
|
-
render(<InteractiveTerminal aria-label="Agent shell" />);
|
|
102
|
-
expect(h.Terminal).not.toHaveBeenCalled();
|
|
103
|
-
await waitFor(() => expect(h.Terminal).toHaveBeenCalledTimes(1));
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("forwards the aria-label onto xterm's real (focusable) textarea", async () => {
|
|
107
|
-
await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
|
|
108
|
-
expect(h.textarea.setAttribute).toHaveBeenCalledWith("aria-label", "Agent shell");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("handle.write() delegates to the xterm instance", async () => {
|
|
112
|
-
const ref = createRef<InteractiveTerminalHandle>();
|
|
113
|
-
await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
|
|
114
|
-
act(() => ref.current?.write("hello\r\n"));
|
|
115
|
-
expect(h.instances[0]?.write).toHaveBeenCalledWith("hello\r\n");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("handle.clear() / handle.focus() delegate to the xterm instance", async () => {
|
|
119
|
-
const ref = createRef<InteractiveTerminalHandle>();
|
|
120
|
-
await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
|
|
121
|
-
act(() => ref.current?.clear());
|
|
122
|
-
act(() => ref.current?.focus());
|
|
123
|
-
expect(h.instances[0]?.clear).toHaveBeenCalledTimes(1);
|
|
124
|
-
expect(h.instances[0]?.focus).toHaveBeenCalledTimes(1);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("handle.fit() delegates to the FitAddon instance", async () => {
|
|
128
|
-
const ref = createRef<InteractiveTerminalHandle>();
|
|
129
|
-
await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
|
|
130
|
-
const fitAddon = h.FitAddon.mock.results[0]?.value as { fit: ReturnType<typeof vi.fn> };
|
|
131
|
-
act(() => ref.current?.fit());
|
|
132
|
-
expect(fitAddon.fit).toHaveBeenCalled();
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("wires onData to the onData prop (keystrokes + paste)", async () => {
|
|
136
|
-
const onData = vi.fn();
|
|
137
|
-
await renderTerminal(<InteractiveTerminal aria-label="Agent shell" onData={onData} />);
|
|
138
|
-
await waitFor(() => expect(h.instances[0]?.onData).toHaveBeenCalledTimes(1));
|
|
139
|
-
act(() => h.state.dataHandler?.("ls\n"));
|
|
140
|
-
expect(onData).toHaveBeenCalledWith("ls\n");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("reports container resize as cols/rows via onResize", async () => {
|
|
144
|
-
const onResize = vi.fn();
|
|
145
|
-
await renderTerminal(<InteractiveTerminal aria-label="Agent shell" onResize={onResize} />);
|
|
146
|
-
await waitFor(() => expect(h.instances[0]?.onResize).toHaveBeenCalledTimes(1));
|
|
147
|
-
act(() => h.state.resizeHandler?.({ cols: 80, rows: 24 }));
|
|
148
|
-
expect(onResize).toHaveBeenCalledWith({ cols: 80, rows: 24 });
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe("readOnly", () => {
|
|
152
|
-
it("constructs xterm with disableStdin: true", async () => {
|
|
153
|
-
await renderTerminal(<InteractiveTerminal aria-label="Agent log" readOnly />);
|
|
154
|
-
expect(h.instances[0]?.options.disableStdin).toBe(true);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("does not wire onData", async () => {
|
|
158
|
-
const onData = vi.fn();
|
|
159
|
-
await renderTerminal(<InteractiveTerminal aria-label="Agent log" onData={onData} readOnly />);
|
|
160
|
-
await flush();
|
|
161
|
-
expect(h.instances[0]?.onData).not.toHaveBeenCalled();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("still exposes write() so consumers can render output", async () => {
|
|
165
|
-
const ref = createRef<InteractiveTerminalHandle>();
|
|
166
|
-
await renderTerminal(<InteractiveTerminal ref={ref} aria-label="Agent log" readOnly />);
|
|
167
|
-
act(() => ref.current?.write("log line\r\n"));
|
|
168
|
-
expect(h.instances[0]?.write).toHaveBeenCalledWith("log line\r\n");
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("disposes the xterm instance on unmount", async () => {
|
|
173
|
-
const { unmount } = await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
|
|
174
|
-
unmount();
|
|
175
|
-
expect(h.instances[0]?.dispose).toHaveBeenCalledTimes(1);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("replays writes issued before the engine chunk resolved (#313)", async () => {
|
|
179
|
-
const ref = createRef<InteractiveTerminalHandle>();
|
|
180
|
-
render(<InteractiveTerminal ref={ref} aria-label="Agent shell" />);
|
|
181
|
-
|
|
182
|
-
// A consumer's own mount effect runs before the lazy chunk lands — the
|
|
183
|
-
// banner it writes must not be silently dropped.
|
|
184
|
-
act(() => ref.current?.write("banner\r\n"));
|
|
185
|
-
expect(h.instances).toHaveLength(0);
|
|
186
|
-
|
|
187
|
-
await waitFor(() => expect(h.instances[0]?.write).toHaveBeenCalledWith("banner\r\n"));
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("unmounting before the engine chunk resolves never opens a terminal (#313)", async () => {
|
|
191
|
-
// Warm the module cache first, so the second render's dynamic import really
|
|
192
|
-
// does resolve on this tick — otherwise the assertion would pass vacuously.
|
|
193
|
-
const warm = await renderTerminal(<InteractiveTerminal aria-label="Agent shell" />);
|
|
194
|
-
warm.unmount();
|
|
195
|
-
h.instances.length = 0;
|
|
196
|
-
|
|
197
|
-
const { unmount } = render(<InteractiveTerminal aria-label="Agent shell" />);
|
|
198
|
-
unmount();
|
|
199
|
-
await flush();
|
|
200
|
-
expect(h.instances).toHaveLength(0);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
describe("buildInteractiveTerminalTheme", () => {
|
|
205
|
-
afterEach(() => {
|
|
206
|
-
document.documentElement.removeAttribute("style");
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("maps ANSI red to the resolved --destructive-text token", () => {
|
|
210
|
-
document.documentElement.style.setProperty("--destructive-text", "#dc2626");
|
|
211
|
-
expect(buildInteractiveTerminalTheme().red).toBe("#dc2626");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// Fixture values are real AA-clearing `-text` rungs (≥4.5:1 on the default
|
|
215
|
-
// `#ffffff` background this test leaves in place). A `-text` token that does
|
|
216
|
-
// NOT clear AA is not a valid fixture for this mapping — the readable-ink
|
|
217
|
-
// floor below would (correctly) darken it and the identity assertion would
|
|
218
|
-
// stop describing the mapping.
|
|
219
|
-
it("maps ANSI green/yellow/blue to their -text tokens", () => {
|
|
220
|
-
document.documentElement.style.setProperty("--success-text", "#15803d");
|
|
221
|
-
document.documentElement.style.setProperty("--warning-text", "#854d0e");
|
|
222
|
-
document.documentElement.style.setProperty("--info-text", "#1d4ed8");
|
|
223
|
-
const theme = buildInteractiveTerminalTheme();
|
|
224
|
-
expect(theme.green).toBe("#15803d");
|
|
225
|
-
expect(theme.yellow).toBe("#854d0e");
|
|
226
|
-
expect(theme.blue).toBe("#1d4ed8");
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("resolves an oklch() token value to hex, not passed through raw", () => {
|
|
230
|
-
document.documentElement.style.setProperty("--destructive-text", "oklch(0.5 0.2 27)");
|
|
231
|
-
expect(buildInteractiveTerminalTheme().red).toMatch(/^#[0-9a-f]{6}$/);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("derives background/foreground/cursor from --card/--foreground/--primary", () => {
|
|
235
|
-
document.documentElement.style.setProperty("--card", "#111827");
|
|
236
|
-
document.documentElement.style.setProperty("--foreground", "#f9fafb");
|
|
237
|
-
document.documentElement.style.setProperty("--primary", "#6366f1");
|
|
238
|
-
const theme = buildInteractiveTerminalTheme();
|
|
239
|
-
expect(theme.background).toBe("#111827");
|
|
240
|
-
expect(theme.foreground).toBe("#f9fafb");
|
|
241
|
-
expect(theme.cursor).toBe("#6366f1");
|
|
242
|
-
// cursorAccent tracks the background so glyphs stay legible under a block cursor.
|
|
243
|
-
expect(theme.cursorAccent).toBe("#111827");
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("keeps black/white as background/foreground rungs, not the same value", () => {
|
|
247
|
-
document.documentElement.style.setProperty("--card", "#0b1220");
|
|
248
|
-
document.documentElement.style.setProperty("--foreground", "#e5e7eb");
|
|
249
|
-
const theme = buildInteractiveTerminalTheme();
|
|
250
|
-
expect(theme.black).toBe("#0b1220");
|
|
251
|
-
expect(theme.brightWhite).toBe("#e5e7eb");
|
|
252
|
-
expect(theme.black).not.toBe(theme.brightWhite);
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// --- #386: every ANSI slot is TEXT, so every one of them must clear AA ------
|
|
257
|
-
// xterm paints ANSI colour codes as real text on the terminal background. The
|
|
258
|
-
// original mapping reached for MARK-rung tokens (`--chart-2`/`--chart-4`,
|
|
259
|
-
// `--border-strong`) and FILL-rung tokens (`--success`/`--info`/… for the
|
|
260
|
-
// `bright*` siblings), which are only guaranteed ≥3:1 (see
|
|
261
|
-
// .claude/rules/styling-and-tokens.md, "which status rung a graphical MARK
|
|
262
|
-
// reaches for"), so slots came out below 4.5:1 in EVERY palette this repo ships.
|
|
263
|
-
//
|
|
264
|
-
// The fixtures below are PARSED FROM `packages/tokens/src/themes.css` at test
|
|
265
|
-
// time and resolved with the same `oklchToHex` the component itself uses —
|
|
266
|
-
// deliberately not hand-copied hexes. An earlier revision of this file DID
|
|
267
|
-
// hand-copy them and got them wrong while claiming they were measured; a parsed
|
|
268
|
-
// fixture cannot drift from the shipped themes and cannot be fabricated.
|
|
269
|
-
// `resolveBlock` THROWS on a token it cannot resolve, so a broken parse fails
|
|
270
|
-
// loudly instead of silently substituting a fallback that happens to pass.
|
|
271
|
-
|
|
272
|
-
/** WCAG 2.1 relative luminance of a `#rrggbb` — the reference implementation
|
|
273
|
-
* this test checks the component's own clamp against. */
|
|
274
|
-
function relativeLuminance(hex: string): number {
|
|
275
|
-
const channel = (i: number) => {
|
|
276
|
-
const c = (parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) || 0) / 255;
|
|
277
|
-
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
278
|
-
};
|
|
279
|
-
return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function contrastRatio(a: string, b: string): number {
|
|
283
|
-
const la = relativeLuminance(a);
|
|
284
|
-
const lb = relativeLuminance(b);
|
|
285
|
-
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ADR 0029 split the reference themes out of `themes.css` into their own opt-in
|
|
289
|
-
// files, so a reader that opens ONLY `themes.css` sees `:root` and the paused
|
|
290
|
-
// engine block and nothing else — it keeps parsing, just less. Read the SET,
|
|
291
|
-
// and throw rather than return an incomplete one.
|
|
292
|
-
const TOKENS_SRC = join(dirname(fileURLToPath(import.meta.url)), "../../tokens/src");
|
|
293
|
-
|
|
294
|
-
function readThemeCssSet(): string {
|
|
295
|
-
const engine = join(TOKENS_SRC, "themes.css");
|
|
296
|
-
const themesDir = join(TOKENS_SRC, "themes");
|
|
297
|
-
const files = readdirSync(themesDir).filter((f) => f.endsWith(".css"));
|
|
298
|
-
if (files.length === 0) {
|
|
299
|
-
throw new Error(`no theme stylesheets found in ${themesDir} — the fixture would be vacuous`);
|
|
300
|
-
}
|
|
301
|
-
return [engine, ...files.map((f) => join(themesDir, f))]
|
|
302
|
-
.map((p) => readFileSync(p, "utf8"))
|
|
303
|
-
.join("\n");
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Every custom property declared per `:root` / `[data-theme="…"]` block.
|
|
307
|
-
* Comments are stripped FIRST — themes.css documents its tokens heavily, and a
|
|
308
|
-
* `--foo: bar;` written inside a `/* … *\/` prose block otherwise parses as a
|
|
309
|
-
* declaration and poisons the fixture. */
|
|
310
|
-
function parseThemeBlocks(source: string): Record<string, Record<string, string>> {
|
|
311
|
-
const css = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
312
|
-
const out: Record<string, Record<string, string>> = {};
|
|
313
|
-
const opener = /(^|\n)(:root|\[data-theme="([a-z0-9-]+)"\])\s*\{/g;
|
|
314
|
-
let match: RegExpExecArray | null;
|
|
315
|
-
while ((match = opener.exec(css))) {
|
|
316
|
-
const name = match[3] ?? ":root";
|
|
317
|
-
let depth = 1;
|
|
318
|
-
let i = opener.lastIndex;
|
|
319
|
-
while (i < css.length && depth > 0) {
|
|
320
|
-
if (css[i] === "{") depth += 1;
|
|
321
|
-
else if (css[i] === "}") depth -= 1;
|
|
322
|
-
i += 1;
|
|
323
|
-
}
|
|
324
|
-
const decls = out[name] ?? {};
|
|
325
|
-
for (const d of css
|
|
326
|
-
.slice(opener.lastIndex, i - 1)
|
|
327
|
-
.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;{}]+);/g)) {
|
|
328
|
-
decls[d[1]!] = d[2]!.trim();
|
|
329
|
-
}
|
|
330
|
-
out[name] = decls;
|
|
331
|
-
}
|
|
332
|
-
return out;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** The tokens `buildInteractiveTerminalTheme` reads. Every one must resolve. */
|
|
336
|
-
const READ_TOKENS = [
|
|
337
|
-
"--card",
|
|
338
|
-
"--foreground",
|
|
339
|
-
"--primary",
|
|
340
|
-
"--muted-foreground",
|
|
341
|
-
"--border-strong",
|
|
342
|
-
"--destructive-text",
|
|
343
|
-
"--destructive",
|
|
344
|
-
"--success-text",
|
|
345
|
-
"--success",
|
|
346
|
-
"--warning-text",
|
|
347
|
-
"--warning",
|
|
348
|
-
"--info-text",
|
|
349
|
-
"--info",
|
|
350
|
-
"--chart-2",
|
|
351
|
-
"--chart-4",
|
|
352
|
-
] as const;
|
|
353
|
-
|
|
354
|
-
/** Resolve a block's declarations (following one `var()` hop) to concrete hex.
|
|
355
|
-
* Throws rather than falling back — a silent fallback of `#000000` on a light
|
|
356
|
-
* card reads as 21:1 and would hide exactly the failure this test locks. */
|
|
357
|
-
function resolveBlock(decls: Record<string, string>, blockName: string): Record<string, string> {
|
|
358
|
-
const out: Record<string, string> = {};
|
|
359
|
-
for (const token of READ_TOKENS) {
|
|
360
|
-
let raw = decls[token];
|
|
361
|
-
for (let hop = 0; raw?.startsWith("var(") && hop < 4; hop += 1) {
|
|
362
|
-
raw = decls[raw.slice(4, raw.indexOf(")")).trim()];
|
|
363
|
-
}
|
|
364
|
-
if (!raw) throw new Error(`themes.css block "${blockName}" does not declare ${token}`);
|
|
365
|
-
const hex = raw.startsWith("oklch") ? oklchToHex(raw) : raw;
|
|
366
|
-
if (!hex || !/^#[0-9a-f]{6}$/i.test(hex)) {
|
|
367
|
-
throw new Error(
|
|
368
|
-
`themes.css block "${blockName}": ${token} = "${raw}" did not resolve to hex`,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
out[token] = hex;
|
|
372
|
-
}
|
|
373
|
-
return out;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Every slot xterm paints as INK. `black` is deliberately excluded: it is the
|
|
377
|
-
* background RUNG by ANSI convention (documented in interactive-terminal.tsx),
|
|
378
|
-
* not ink chosen for legibility. */
|
|
379
|
-
const ANSI_INK_SLOTS = [
|
|
380
|
-
"red",
|
|
381
|
-
"green",
|
|
382
|
-
"yellow",
|
|
383
|
-
"blue",
|
|
384
|
-
"magenta",
|
|
385
|
-
"cyan",
|
|
386
|
-
"white",
|
|
387
|
-
"brightBlack",
|
|
388
|
-
"brightRed",
|
|
389
|
-
"brightGreen",
|
|
390
|
-
"brightYellow",
|
|
391
|
-
"brightBlue",
|
|
392
|
-
"brightMagenta",
|
|
393
|
-
"brightCyan",
|
|
394
|
-
"brightWhite",
|
|
395
|
-
] as const;
|
|
396
|
-
|
|
397
|
-
const THEME_BLOCKS = parseThemeBlocks(readThemeCssSet());
|
|
398
|
-
|
|
399
|
-
describe("buildInteractiveTerminalTheme readable-ink floor (#386)", () => {
|
|
400
|
-
afterEach(() => {
|
|
401
|
-
document.documentElement.removeAttribute("style");
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// Guards the parse itself: if the regex ever stops matching, the per-block
|
|
405
|
-
// loop below would silently run zero assertions and still go green.
|
|
406
|
-
it("parses every palette the repo ships out of the theme stylesheet SET", () => {
|
|
407
|
-
expect(Object.keys(THEME_BLOCKS).sort()).toEqual(
|
|
408
|
-
expect.arrayContaining([":root", "light", "dark"]),
|
|
409
|
-
);
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
for (const blockName of [":root", "light", "dark"]) {
|
|
413
|
-
const apply = () => {
|
|
414
|
-
const palette = resolveBlock(THEME_BLOCKS[blockName] ?? {}, blockName);
|
|
415
|
-
for (const [name, value] of Object.entries(palette)) {
|
|
416
|
-
document.documentElement.style.setProperty(name, value);
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
it(`clears WCAG AA (4.5:1) on every ANSI ink slot in ${blockName}`, () => {
|
|
421
|
-
apply();
|
|
422
|
-
const theme = buildInteractiveTerminalTheme();
|
|
423
|
-
const background = theme.background as string;
|
|
424
|
-
const failures = ANSI_INK_SLOTS.filter(
|
|
425
|
-
(slot) => contrastRatio(theme[slot] as string, background) < 4.5,
|
|
426
|
-
).map(
|
|
427
|
-
(slot) =>
|
|
428
|
-
`${slot}=${theme[slot]} (${contrastRatio(theme[slot] as string, background).toFixed(2)}:1)`,
|
|
429
|
-
);
|
|
430
|
-
expect(failures).toEqual([]);
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it(`keeps each bright ANSI sibling distinguishable from its base in ${blockName}`, () => {
|
|
434
|
-
apply();
|
|
435
|
-
const theme = buildInteractiveTerminalTheme();
|
|
436
|
-
// A bright/base pair that collapses to one hex makes `\x1b[1;3Xm` output
|
|
437
|
-
// indistinguishable from `\x1b[3Xm` — the clamp must not flatten the palette.
|
|
438
|
-
expect(theme.brightMagenta).not.toBe(theme.magenta);
|
|
439
|
-
expect(theme.brightCyan).not.toBe(theme.cyan);
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
it("leaves an already-AA ink untouched (the clamp is a floor, not a filter)", () => {
|
|
444
|
-
document.documentElement.style.setProperty("--card", "#ffffff");
|
|
445
|
-
document.documentElement.style.setProperty("--destructive-text", "#b3261e");
|
|
446
|
-
expect(buildInteractiveTerminalTheme().red).toBe("#b3261e");
|
|
447
|
-
});
|
|
448
|
-
});
|