@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.
Files changed (128) hide show
  1. package/README.md +61 -11
  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 +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { type EffortLevel } from "@elabs-ai/components-ui";
5
+ import { PromptInputEffort } from "./prompt-input-effort";
6
+
7
+ const levels: EffortLevel[] = [
8
+ { id: "low", label: "Low" },
9
+ { id: "medium", label: "Medium" },
10
+ { id: "high", label: "High" },
11
+ { id: "max", label: "Max" },
12
+ ];
13
+
14
+ describe("PromptInputEffort", () => {
15
+ it("defaults to the first (lowest) level", () => {
16
+ render(<PromptInputEffort levels={levels} aria-label="Reasoning effort" />);
17
+ expect(screen.getByRole("radio", { name: "Low", checked: true })).toBeInTheDocument();
18
+ expect(screen.getByText("Low")).toBeInTheDocument();
19
+ });
20
+
21
+ it("is uncontrolled: clicking a level fires onValueChange and moves the checked radio", async () => {
22
+ const onValueChange = vi.fn();
23
+ render(
24
+ <PromptInputEffort
25
+ levels={levels}
26
+ aria-label="Reasoning effort"
27
+ onValueChange={onValueChange}
28
+ />,
29
+ );
30
+
31
+ await userEvent.click(screen.getByRole("radio", { name: "High" }));
32
+ expect(onValueChange).toHaveBeenCalledWith("high");
33
+ expect(screen.getByRole("radio", { name: "High", checked: true })).toBeInTheDocument();
34
+ });
35
+
36
+ it("is controlled via `value`", () => {
37
+ render(<PromptInputEffort levels={levels} aria-label="Reasoning effort" value="max" />);
38
+ expect(screen.getByRole("radio", { name: "Max", checked: true })).toBeInTheDocument();
39
+ expect(screen.getByText("Max")).toBeInTheDocument();
40
+ });
41
+
42
+ /**
43
+ * The test the issue asks for: two ADJACENT levels must differ in their
44
+ * ACCESSIBLE TEXT (not merely a class string, which would pass on
45
+ * colour-only code) and in their RENDERED INDICATOR — a structural count of
46
+ * how many rungs are marked filled, not a comparison of two class strings.
47
+ */
48
+ it("two adjacent levels differ in accessible text AND in the rendered fill indicator", () => {
49
+ const { unmount } = render(
50
+ <PromptInputEffort levels={levels} aria-label="Reasoning effort" value="medium" />,
51
+ );
52
+
53
+ // Accessible text: the checked radio's accessible name, and the visible
54
+ // level-name text, both reflect "Medium".
55
+ const mediumChecked = screen.getByRole("radio", { checked: true });
56
+ expect(mediumChecked).toHaveAccessibleName("Medium");
57
+ expect(screen.getByText("Medium")).toBeInTheDocument();
58
+
59
+ // Rendered indicator: a structural count of filled rungs (`data-filled`),
60
+ // not a class-string diff — "Medium" is the 2nd of 4 rungs, so exactly 2
61
+ // are filled.
62
+ const mediumFilledCount = document.querySelectorAll('[data-filled="true"]').length;
63
+ expect(mediumFilledCount).toBe(2);
64
+
65
+ unmount();
66
+
67
+ render(<PromptInputEffort levels={levels} aria-label="Reasoning effort" value="high" />);
68
+
69
+ const highChecked = screen.getByRole("radio", { checked: true });
70
+ expect(highChecked).toHaveAccessibleName("High");
71
+ expect(screen.getByText("High")).toBeInTheDocument();
72
+
73
+ // "High" is the 3rd of 4 (adjacent) rungs, so exactly 3 are filled — one
74
+ // more than "Medium", proving the indicator actually moved.
75
+ const highFilledCount = document.querySelectorAll('[data-filled="true"]').length;
76
+ expect(highFilledCount).toBe(3);
77
+
78
+ // The two accessible names differ, and the two filled counts differ —
79
+ // the assertion this test exists to make.
80
+ expect(mediumChecked).not.toBe(highChecked);
81
+ expect(mediumFilledCount).not.toBe(highFilledCount);
82
+ });
83
+ });
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
5
+ import {
6
+ RadioGroup,
7
+ RadioGroupItem,
8
+ cn,
9
+ effortRungForIndex,
10
+ type EffortLevel,
11
+ } from "@elabs-ai/components-ui";
12
+
13
+ // `EffortLevel` and the growing-square size ramp (`effortRungForIndex`,
14
+ // formerly this file's private `SIZE_RUNGS`/`rungForIndex`) moved to
15
+ // `@elabs-ai/components-ui` (`lib/operating-mode.ts`) — the terminal CLI
16
+ // look-alike family's own effort chips (issue #117) reuse the same
17
+ // vocabulary shape, and `@elabs-ai/components-ai`/
18
+ // `@elabs-ai/components-terminal` are layer-2 DAG siblings that may not
19
+ // import each other (T0; see
20
+ // docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
21
+ // above; NOT re-exported from here — a consumer imports `EffortLevel` from
22
+ // `@elabs-ai/components-ui`.
23
+
24
+ export type PromptInputEffortProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
25
+ /** Ordered low → high. The order IS the semantics — render in this order. */
26
+ levels: EffortLevel[];
27
+ /** Controlled selected level id. Omit for the uncontrolled default (`levels[0]`). */
28
+ value?: string;
29
+ /** Fires with the chosen level's id. */
30
+ onValueChange?: (id: string) => void;
31
+ /**
32
+ * Accessible name for the whole scale (e.g. "Reasoning effort", "Thinking
33
+ * budget") — required because the NAME of the scale is as much the
34
+ * consumer's vocabulary as its levels are; this component has no default
35
+ * to fall back to.
36
+ */
37
+ "aria-label": string;
38
+ };
39
+
40
+ /**
41
+ * PromptInputEffort — an ORDERED reasoning-effort/budget control.
42
+ *
43
+ * Renders `levels` as a row of same-shape squares that GROW in size, low to
44
+ * high — the size ramp encodes the order on its own, before any selection is
45
+ * made. Selecting a level fills every square up to and including it (solid
46
+ * fill + coloured border) and leaves the rest hollow (outline only): the
47
+ * indicator's FILL is the second, non-colour channel required by a
48
+ * greyscale-legibility read — the size ramp and the solid/hollow shape both
49
+ * survive a greyscale render, so the level is recoverable without colour.
50
+ * The current level's name is additionally rendered as text.
51
+ *
52
+ * Built on Radix (`RadioGroup`/`RadioGroupItem` from `@elabs-ai/components-ui`,
53
+ * `role="radiogroup"` of `role="radio"`s) for arrow-key navigation, roving
54
+ * focus and an accessible-name announcement on the checked item — no
55
+ * hand-rolled key handling.
56
+ */
57
+ export const PromptInputEffort = forwardRef<HTMLDivElement, PromptInputEffortProps>(
58
+ function PromptInputEffort(
59
+ { levels, value, onValueChange, className, "aria-label": ariaLabel, ...props },
60
+ ref,
61
+ ) {
62
+ const [selected, setSelected] = useControllableState<string>({
63
+ defaultProp: levels[0]?.id ?? "",
64
+ onChange: onValueChange,
65
+ prop: value,
66
+ });
67
+
68
+ const handleChange = (next: string) => {
69
+ if (!next) return;
70
+ setSelected(next);
71
+ };
72
+
73
+ const currentIndex = levels.findIndex((level) => level.id === selected);
74
+ const current = currentIndex >= 0 ? levels[currentIndex] : levels[0];
75
+
76
+ return (
77
+ <div
78
+ ref={ref}
79
+ data-slot="prompt-input-effort"
80
+ className={cn("inline-flex items-center gap-2", className)}
81
+ {...props}
82
+ >
83
+ <RadioGroup
84
+ value={selected}
85
+ onValueChange={handleChange}
86
+ orientation="horizontal"
87
+ aria-label={ariaLabel}
88
+ data-slot="prompt-input-effort-scale"
89
+ className="flex items-end gap-1"
90
+ >
91
+ {levels.map((level, index) => {
92
+ const filled = currentIndex >= 0 && index <= currentIndex;
93
+ return (
94
+ // The visible bar (`-visual`) and the click/tap/focus target
95
+ // (the radio itself) are decoupled (WCAG 2.5.8, #161): the
96
+ // radio's authored size IS the ramp's meaning, so it can't
97
+ // simply grow to 24px without flattening the ramp. Instead the
98
+ // radio is stretched to fill this `size-6` (24px) frame —
99
+ // invisible, but a real >=24x24 hit box — while a separate,
100
+ // `aria-hidden` sibling paints the small bar at its authored
101
+ // `effortRungForIndex` size. Both are bottom-flush + centered
102
+ // so the radio's own checked-state indicator still lands on
103
+ // (and stays invisible against) the visible bar, exactly as
104
+ // when the radio painted the bar directly.
105
+ <span
106
+ key={level.id}
107
+ data-slot="prompt-input-effort-item-frame"
108
+ className="relative inline-flex size-6 shrink-0 items-end justify-center"
109
+ >
110
+ <span
111
+ aria-hidden="true"
112
+ data-slot="prompt-input-effort-item-visual"
113
+ className={cn(
114
+ effortRungForIndex(index, levels.length),
115
+ "rounded-sm border",
116
+ filled ? "border-primary bg-primary" : "border-border-strong bg-transparent",
117
+ )}
118
+ />
119
+ <RadioGroupItem
120
+ value={level.id}
121
+ aria-label={level.label}
122
+ data-slot="prompt-input-effort-item"
123
+ data-filled={filled ? "true" : "false"}
124
+ className="absolute inset-0 flex size-full items-end justify-center rounded-sm border-0 bg-transparent p-0 shadow-none"
125
+ />
126
+ </span>
127
+ );
128
+ })}
129
+ </RadioGroup>
130
+ <span className="text-body" data-slot="prompt-input-effort-label">
131
+ {current?.label}
132
+ </span>
133
+ </div>
134
+ );
135
+ },
136
+ );
@@ -0,0 +1,108 @@
1
+ /**
2
+ * PromptInputMode — a composer control for an app-defined operating mode
3
+ * (#107). Sits beside the model pill / tools cluster in a `PromptInput`
4
+ * footer: a compact trigger showing the current mode, opening a menu of
5
+ * every mode the app supports. Ships no mode vocabulary of its own — the
6
+ * example modes below are demo data, not a canonical set.
7
+ *
8
+ * Distinct from `PermissionModeSelect` (#104), which renders the SAME kind
9
+ * of app-defined policy as an always-expanded settings-panel card list with
10
+ * a required consequence sentence per mode. This is the composer-toolbar
11
+ * altitude: a collapsed trigger, an optional one-line description, and an
12
+ * optional shortcut hint — the two are siblings, not a duplicate.
13
+ */
14
+ import type { Meta, StoryObj } from "@storybook/react-vite";
15
+ import { expect, fn, within } from "storybook/test";
16
+ import { Bot, PencilLine, ShieldCheck } from "lucide-react";
17
+ import { type OperatingMode } from "@elabs-ai/components-ui";
18
+
19
+ import { PromptInputMode } from "./prompt-input-mode";
20
+
21
+ const MODES: OperatingMode[] = [
22
+ {
23
+ id: "auto",
24
+ label: "Auto",
25
+ description: "Acts without asking, within its usual limits.",
26
+ icon: <Bot className="size-4" aria-hidden="true" />,
27
+ },
28
+ {
29
+ id: "plan",
30
+ label: "Plan first",
31
+ description: "Proposes a plan and waits for approval before acting.",
32
+ keyHint: "⇧ Tab",
33
+ icon: <PencilLine className="size-4" aria-hidden="true" />,
34
+ },
35
+ {
36
+ id: "review",
37
+ label: "Review edits",
38
+ description: "Acts, but pauses on every file edit for a quick review.",
39
+ icon: <ShieldCheck className="size-4" aria-hidden="true" />,
40
+ },
41
+ ];
42
+
43
+ const meta = {
44
+ title: "AI/Composer/PromptInputMode",
45
+ component: PromptInputMode,
46
+ parameters: {
47
+ layout: "padded",
48
+ docs: {
49
+ description: {
50
+ component:
51
+ "A part of the [Composer](?path=/docs/ai-composer--docs) family — what the `mode` slot renders. A composer control for an app-defined operating mode — how autonomously the agent may act. The trigger shows the current mode's icon + label; opening it lists every mode with its optional description and shortcut hint. `modes` is entirely prop-driven — no mode vocabulary is hardcoded.",
52
+ },
53
+ },
54
+ },
55
+ args: {
56
+ modes: MODES,
57
+ onValueChange: fn(),
58
+ },
59
+ tags: ["autodocs"],
60
+ } satisfies Meta<typeof PromptInputMode>;
61
+
62
+ export default meta;
63
+ type Story = StoryObj<typeof meta>;
64
+
65
+ /** Uncontrolled — defaults to the first mode ("Auto"). */
66
+ export const Default: Story = {};
67
+
68
+ /** Opening the trigger lists every mode with its description and key hint. */
69
+ export const OpenMenu: Story = {
70
+ play: async ({ canvas, userEvent }) => {
71
+ const trigger = canvas.getByRole("button", { name: "Auto" });
72
+ await userEvent.click(trigger);
73
+
74
+ // Radix portals the menu to document.body, so it is OUTSIDE the story
75
+ // canvas. jsdom is forgiving about this; a real browser is not.
76
+ const menu = within(document.body);
77
+ const planOption = await menu.findByRole("menuitemradio", { name: "Plan first" });
78
+ await expect(planOption).toBeInTheDocument();
79
+ // The description and shortcut are visible but excluded from the
80
+ // accessible name (#153) — never let this drift back to a regex match,
81
+ // which would pass even with the extra text folded back in.
82
+ await expect(planOption).toHaveAccessibleName("Plan first");
83
+ await expect(
84
+ menu.getByText("Proposes a plan and waits for approval before acting."),
85
+ ).toBeInTheDocument();
86
+ await expect(menu.getByText("⇧ Tab")).toBeInTheDocument();
87
+ },
88
+ };
89
+
90
+ /** Selecting a mode updates the trigger and fires `onValueChange` once. */
91
+ export const SelectAMode: Story = {
92
+ play: async ({ canvas, args, userEvent }) => {
93
+ await userEvent.click(canvas.getByRole("button", { name: "Auto" }));
94
+ const reviewOption = await within(document.body).findByRole("menuitemradio", {
95
+ name: "Review edits",
96
+ });
97
+ await userEvent.click(reviewOption);
98
+
99
+ await expect(args.onValueChange).toHaveBeenCalledWith("review");
100
+ const trigger = await canvas.findByRole("button");
101
+ await expect(trigger).toHaveAccessibleName("Review edits");
102
+ },
103
+ };
104
+
105
+ /** Controlled via `value` — the app owns which mode is shown. */
106
+ export const Controlled: Story = {
107
+ args: { value: "plan" },
108
+ };
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { type OperatingMode } from "@elabs-ai/components-ui";
5
+ import { PromptInputMode } from "./prompt-input-mode";
6
+
7
+ const modes: OperatingMode[] = [
8
+ { id: "auto", label: "Auto", description: "Acts without asking" },
9
+ {
10
+ id: "plan",
11
+ label: "Plan first",
12
+ description: "Proposes a plan before acting",
13
+ keyHint: "⇧ Tab",
14
+ },
15
+ ];
16
+
17
+ describe("PromptInputMode", () => {
18
+ it("defaults to the first mode and shows it on the trigger", () => {
19
+ render(<PromptInputMode modes={modes} />);
20
+ const trigger = screen.getByRole("button");
21
+ expect(trigger).toHaveAccessibleName("Auto");
22
+ });
23
+
24
+ it("is uncontrolled: opens the menu, lists every mode, and selects one on click", async () => {
25
+ const onValueChange = vi.fn();
26
+ render(<PromptInputMode modes={modes} onValueChange={onValueChange} />);
27
+
28
+ await userEvent.click(screen.getByRole("button", { name: "Auto" }));
29
+ const planOption = await screen.findByRole("menuitemradio", { name: "Plan first" });
30
+ expect(planOption).toHaveAccessibleName("Plan first");
31
+ expect(planOption).toHaveAttribute("aria-checked", "false");
32
+
33
+ await userEvent.click(planOption);
34
+ expect(onValueChange).toHaveBeenCalledWith("plan");
35
+ // The trigger's visible label updates to reflect the new selection.
36
+ const trigger = await screen.findByRole("button");
37
+ expect(trigger).toHaveAccessibleName("Plan first");
38
+ });
39
+
40
+ it("is controlled via `value`: the app owns the selection, not the component", async () => {
41
+ const onValueChange = vi.fn();
42
+ const { rerender } = render(
43
+ <PromptInputMode modes={modes} value="plan" onValueChange={onValueChange} />,
44
+ );
45
+ expect(screen.getByRole("button")).toHaveAccessibleName("Plan first");
46
+
47
+ await userEvent.click(screen.getByRole("button", { name: "Plan first" }));
48
+ const autoOption = await screen.findByRole("menuitemradio", { name: "Auto" });
49
+ expect(autoOption).toHaveAccessibleName("Auto");
50
+ await userEvent.click(autoOption);
51
+
52
+ expect(onValueChange).toHaveBeenCalledWith("auto");
53
+ // A controlled component does not move on its own — the trigger only
54
+ // updates once the app feeds the new value back in.
55
+ expect(screen.getByRole("button")).toHaveAccessibleName("Plan first");
56
+
57
+ rerender(<PromptInputMode modes={modes} value="auto" onValueChange={onValueChange} />);
58
+ expect(screen.getByRole("button")).toHaveAccessibleName("Auto");
59
+ });
60
+
61
+ it("renders a mode's description and key hint inside the menu", async () => {
62
+ render(<PromptInputMode modes={modes} />);
63
+ await userEvent.click(screen.getByRole("button", { name: "Auto" }));
64
+
65
+ expect(await screen.findByText("Proposes a plan before acting")).toBeInTheDocument();
66
+ expect(screen.getByText("⇧ Tab")).toBeInTheDocument();
67
+ });
68
+
69
+ it("gives a menu item an accessible name that is exactly its label — the description and key hint never join it (#153)", async () => {
70
+ render(<PromptInputMode modes={modes} />);
71
+ await userEvent.click(screen.getByRole("button", { name: "Auto" }));
72
+
73
+ const planOption = await screen.findByRole("menuitemradio", { name: "Plan first" });
74
+ expect(planOption).toHaveAccessibleName("Plan first");
75
+
76
+ // The description is still associated for assistive tech — as a
77
+ // description, not folded into the name.
78
+ expect(planOption).toHaveAccessibleDescription("Proposes a plan before acting");
79
+ });
80
+
81
+ it("resolves the accessible description even when a consumer-supplied mode id contains whitespace (#153)", async () => {
82
+ const modesWithSpaceId: OperatingMode[] = [
83
+ { id: "auto", label: "Auto", description: "Acts without asking" },
84
+ {
85
+ id: "plan first",
86
+ label: "Plan first",
87
+ description: "Proposes a plan before acting",
88
+ },
89
+ ];
90
+ render(<PromptInputMode modes={modesWithSpaceId} />);
91
+ await userEvent.click(screen.getByRole("button", { name: "Auto" }));
92
+
93
+ const planOption = await screen.findByRole("menuitemradio", { name: "Plan first" });
94
+ // `aria-describedby` is a space-separated token list of IDs — an id
95
+ // built from a raw mode value containing whitespace (e.g. "plan first")
96
+ // splits into two references, breaking the association.
97
+ expect(planOption).toHaveAccessibleDescription("Proposes a plan before acting");
98
+ });
99
+ });
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useId, type HTMLAttributes } from "react";
4
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
5
+ import { ChevronDown } from "lucide-react";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuRadioGroup,
10
+ DropdownMenuRadioItem,
11
+ DropdownMenuTrigger,
12
+ Kbd,
13
+ cn,
14
+ type OperatingMode,
15
+ } from "@elabs-ai/components-ui";
16
+
17
+ import { PromptInputButton } from "./prompt-input";
18
+
19
+ // `OperatingMode` moved to `@elabs-ai/components-ui` (`lib/operating-mode.ts`)
20
+ // — the terminal CLI look-alike family's own composer chips (issue #117) reuse
21
+ // the same vocabulary shape, and `@elabs-ai/components-ai`/
22
+ // `@elabs-ai/components-terminal` are layer-2 DAG siblings that may not import
23
+ // each other (T0; see
24
+ // docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
25
+ // above; NOT re-exported from here — `@elabs-ai/components-ai` never
26
+ // re-exports from `@elabs-ai/components-ui`. A consumer imports
27
+ // `OperatingMode` from `@elabs-ai/components-ui`.
28
+
29
+ export type PromptInputModeProps = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
30
+ /** The app's own operating modes, low to high ceremony or however it orders them. */
31
+ modes: OperatingMode[];
32
+ /** Controlled selected mode id. Omit for the uncontrolled default (`modes[0]`). */
33
+ value?: string;
34
+ /** Fires with the chosen mode's id. */
35
+ onValueChange?: (id: string) => void;
36
+ /**
37
+ * Accessible name for the trigger control, read alongside the current
38
+ * mode's label. Optional — the trigger already carries the current mode's
39
+ * visible text as its name, so this only adds context (e.g. "Operating
40
+ * mode: Auto" instead of just "Auto").
41
+ */
42
+ "aria-label"?: string;
43
+ };
44
+
45
+ /**
46
+ * PromptInputMode — a composer control for an app-defined operating mode
47
+ * (how autonomously the agent may act on the next turn).
48
+ *
49
+ * A `DropdownMenu` of `modes`, radio-selected so exactly one is active at a
50
+ * time; the trigger shows the current mode's icon + label. Ships no mode
51
+ * vocabulary — `modes` is entirely prop-driven, so `auto`/`plan`/whatever a
52
+ * given agent calls its modes lives in the consuming app, not here.
53
+ *
54
+ * Built on Radix (`DropdownMenu` + `DropdownMenuRadioGroup`/`RadioItem` from
55
+ * `@elabs-ai/components-ui`) for keyboard navigation, roving focus and a
56
+ * `menuitemradio` role whose `aria-checked` announces the current selection —
57
+ * no hand-rolled key handling.
58
+ */
59
+ export const PromptInputMode = forwardRef<HTMLDivElement, PromptInputModeProps>(
60
+ function PromptInputMode(
61
+ { modes, value, onValueChange, className, "aria-label": ariaLabel, ...props },
62
+ ref,
63
+ ) {
64
+ const [selected, setSelected] = useControllableState<string>({
65
+ defaultProp: modes[0]?.id ?? "",
66
+ onChange: onValueChange,
67
+ prop: value,
68
+ });
69
+
70
+ const handleChange = (next: string) => {
71
+ // Radix would otherwise allow an empty-string commit if a caller wires
72
+ // a deselectable radio group elsewhere — a mode picker always has an
73
+ // active mode, so an empty commit is ignored.
74
+ if (!next) return;
75
+ setSelected(next);
76
+ };
77
+
78
+ const current = modes.find((mode) => mode.id === selected) ?? modes[0];
79
+
80
+ // `OperatingMode.id` is an unconstrained, consumer-owned string — it may
81
+ // contain whitespace or other characters that are invalid inside a
82
+ // single `aria-describedby` token (a space-separated ID list splits on
83
+ // whitespace). Derive a per-instance, DOM-safe id from `useId()` and the
84
+ // mode's index instead of embedding the raw value.
85
+ const descriptionIdBase = useId();
86
+
87
+ return (
88
+ <div
89
+ ref={ref}
90
+ data-slot="prompt-input-mode"
91
+ className={cn("inline-flex", className)}
92
+ {...props}
93
+ >
94
+ {/*
95
+ * `modal={false}` is load-bearing, not a preference. Radix's modal
96
+ * dropdown marks the rest of the document `aria-hidden` while the menu
97
+ * is open — including this component's own trigger — which axe flags as
98
+ * `aria-hidden-focus` ("ARIA hidden element must not be focusable")
99
+ * and which drops the trigger out of the accessibility tree entirely.
100
+ * A composer toolbar chip has no business hiding the page or locking
101
+ * its scroll, so the non-modal layer is both the accessible answer and
102
+ * the semantically correct one.
103
+ */}
104
+ <DropdownMenu modal={false}>
105
+ <DropdownMenuTrigger asChild>
106
+ <PromptInputButton
107
+ variant="ghost"
108
+ className="gap-1.5 rounded-full"
109
+ disabled={modes.length === 0}
110
+ aria-label={
111
+ ariaLabel && current ? `${ariaLabel}: ${current.label}` : (ariaLabel ?? undefined)
112
+ }
113
+ data-slot="prompt-input-mode-trigger"
114
+ >
115
+ {current?.icon}
116
+ <span>{current?.label}</span>
117
+ <ChevronDown className="size-3.5 opacity-60" aria-hidden="true" />
118
+ </PromptInputButton>
119
+ </DropdownMenuTrigger>
120
+
121
+ <DropdownMenuContent align="start" data-slot="prompt-input-mode-content" className="w-72">
122
+ <DropdownMenuRadioGroup value={selected} onValueChange={handleChange}>
123
+ {modes.map((mode, index) => {
124
+ const descriptionId =
125
+ mode.description === undefined
126
+ ? undefined
127
+ : `${descriptionIdBase}-description-${index}`;
128
+ return (
129
+ <DropdownMenuRadioItem
130
+ key={mode.id}
131
+ value={mode.id}
132
+ data-slot="prompt-input-mode-item"
133
+ className="gap-2"
134
+ aria-describedby={descriptionId}
135
+ >
136
+ {mode.icon}
137
+ <span className="flex min-w-0 flex-1 flex-col">
138
+ <span className="truncate text-body">{mode.label}</span>
139
+ {mode.description === undefined ? null : (
140
+ // Visible, but excluded from the item's accessible
141
+ // name (aria-hidden) so the name stays exactly the
142
+ // label; still exposed as the accessible DESCRIPTION
143
+ // via aria-describedby above — a hidden node an
144
+ // aria-describedby reference points to is still read
145
+ // for the description computation.
146
+ <span
147
+ id={descriptionId}
148
+ aria-hidden="true"
149
+ className="truncate text-meta text-muted-foreground"
150
+ >
151
+ {mode.description}
152
+ </span>
153
+ )}
154
+ </span>
155
+ {mode.keyHint === undefined ? null : (
156
+ <Kbd className="shrink-0" aria-hidden="true">
157
+ {mode.keyHint}
158
+ </Kbd>
159
+ )}
160
+ </DropdownMenuRadioItem>
161
+ );
162
+ })}
163
+ </DropdownMenuRadioGroup>
164
+ </DropdownMenuContent>
165
+ </DropdownMenu>
166
+ </div>
167
+ );
168
+ },
169
+ );