@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,211 @@
1
+ /**
2
+ * PromptInputSlash — a `/`-triggered slash-command palette for the composer.
3
+ *
4
+ * Typing `/` at the START of a line (never mid-word) opens a filtered listbox
5
+ * of app-defined commands; arrow keys move the highlight, Enter/Tab inserts
6
+ * `/name `, Escape dismisses — and, like `MentionInput`, **focus never leaves
7
+ * the textarea**. Ships no command vocabulary of its own; `commands` is
8
+ * entirely prop-driven.
9
+ */
10
+ import { useRef, useState } from "react";
11
+ import type { Meta, StoryObj } from "@storybook/react-vite";
12
+ import { expect, userEvent, waitFor, within } from "storybook/test";
13
+ import { type SlashCommand } from "@elabs-ai/components-ui";
14
+
15
+ import { PromptInput } from "./prompt-input";
16
+ import { PromptInputSlash, PromptInputSlashTextarea } from "./prompt-input-slash";
17
+
18
+ const COMMANDS: SlashCommand[] = [
19
+ { name: "help", description: "Show available commands" },
20
+ { name: "history", description: "Show recent conversation history" },
21
+ { name: "hide", description: "Hide this panel" },
22
+ { name: "clear", description: "Clear the conversation" },
23
+ { name: "settings", description: "Open workspace settings" },
24
+ ];
25
+
26
+ /** The stateful arrangement every story below composes — mirrors the docblock example. */
27
+ function Field({ commands = COMMANDS }: { commands?: SlashCommand[] }) {
28
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
29
+ const [text, setText] = useState("");
30
+ return (
31
+ <div className="mx-auto w-[28rem]">
32
+ <PromptInput onSubmit={() => undefined}>
33
+ <PromptInputSlash
34
+ commands={commands}
35
+ value={text}
36
+ textareaRef={textareaRef}
37
+ onValueChange={(next) => setText(next.text)}
38
+ >
39
+ <PromptInputSlashTextarea
40
+ aria-label="Message"
41
+ value={text}
42
+ onChange={(event) => setText(event.target.value)}
43
+ placeholder="Type / for commands…"
44
+ />
45
+ </PromptInputSlash>
46
+ </PromptInput>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ const meta = {
52
+ title: "AI/Composer/PromptInputSlash",
53
+ component: PromptInputSlash,
54
+ parameters: {
55
+ layout: "padded",
56
+ docs: {
57
+ description: {
58
+ component:
59
+ "A part of the [Composer](?path=/docs/ai-composer--docs) family — what the `slashCommands` slot renders. " +
60
+ "The CHAT composer slash menu; the console one is `Terminal/TerminalSlashMenu` and the " +
61
+ "Milkdown editor has its own — see " +
62
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
63
+ "A /-triggered command palette for the composer. Triggers at the start of a line only, filters case-insensitively by prefix, and keeps focus in the textarea throughout — aria-activedescendant on the field always names the highlighted option.",
64
+ },
65
+ },
66
+ },
67
+ tags: ["autodocs"],
68
+ } satisfies Meta<typeof PromptInputSlash>;
69
+
70
+ export default meta;
71
+ type Story = StoryObj<typeof meta>;
72
+
73
+ /** Resting state — an empty field with nothing but its placeholder. */
74
+ export const Default: Story = {
75
+ render: () => <Field />,
76
+ };
77
+
78
+ /**
79
+ * Typing `/` at the start of the line opens the palette; arrow keys move the
80
+ * highlight without ever moving focus off the textarea.
81
+ */
82
+ export const PaletteOpen: Story = {
83
+ render: () => <Field />,
84
+ play: async ({ canvasElement, step }) => {
85
+ const canvas = within(canvasElement);
86
+ const field = canvas.getByRole("textbox", { name: "Message" });
87
+
88
+ await step("typing '/' at the start of the line opens the palette", async () => {
89
+ await userEvent.click(field);
90
+ await userEvent.keyboard("/h");
91
+ await expect(within(document.body).getByRole("listbox")).toBeInTheDocument();
92
+ });
93
+
94
+ await step("arrow keys move the highlight without moving focus", async () => {
95
+ await userEvent.keyboard("{ArrowDown}");
96
+ await expect(field).toHaveFocus();
97
+
98
+ const activeId = field.getAttribute("aria-activedescendant");
99
+ await expect(activeId).toBeTruthy();
100
+ const active = document.getElementById(activeId!);
101
+ await expect(active).not.toBeNull();
102
+ await expect(active).toHaveAttribute("aria-selected", "true");
103
+ });
104
+ },
105
+ };
106
+
107
+ /**
108
+ * Typing `/` mid-word (not at the start of a line) never opens the palette —
109
+ * the acceptance test for the line-start boundary.
110
+ */
111
+ export const NotMidWord: Story = {
112
+ render: () => <Field />,
113
+ play: async ({ canvasElement }) => {
114
+ const canvas = within(canvasElement);
115
+ const field = canvas.getByRole("textbox", { name: "Message" });
116
+ await userEvent.click(field);
117
+ await userEvent.keyboard("cd /usr");
118
+ await expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument();
119
+ },
120
+ };
121
+
122
+ /**
123
+ * Narrowing the query clamps the highlighted option into the filtered list —
124
+ * it can never point at an option that has scrolled out of the results.
125
+ */
126
+ export const NarrowingClampsTheHighlight: Story = {
127
+ render: () => <Field />,
128
+ play: async ({ canvasElement }) => {
129
+ const canvas = within(canvasElement);
130
+ const field = canvas.getByRole("textbox", { name: "Message" });
131
+ await userEvent.click(field);
132
+ await userEvent.keyboard("/h");
133
+ await expect(within(document.body).getAllByRole("option")).toHaveLength(3);
134
+
135
+ // Highlight the LAST of the three "h" matches.
136
+ await userEvent.keyboard("{ArrowDown}{ArrowDown}");
137
+
138
+ // Narrow to the single command that still matches.
139
+ await userEvent.keyboard("id");
140
+ const options = within(document.body).getAllByRole("option");
141
+ await expect(options).toHaveLength(1);
142
+ await expect(options[0]).toHaveAttribute("aria-selected", "true");
143
+ await expect(field).toHaveAttribute("aria-activedescendant", options[0]!.id);
144
+ },
145
+ };
146
+
147
+ /** Enter inserts the highlighted command and closes the palette. */
148
+ export const SelectWithEnter: Story = {
149
+ render: () => <Field />,
150
+ play: async ({ canvasElement }) => {
151
+ const canvas = within(canvasElement);
152
+ const field = canvas.getByRole("textbox", { name: "Message" }) as HTMLTextAreaElement;
153
+ await userEvent.click(field);
154
+ await userEvent.keyboard("/h");
155
+ await userEvent.keyboard("{Enter}");
156
+
157
+ await expect(field.value).toBe("/help ");
158
+ // The popover exits with an animation (Radix Presence keeps it mounted
159
+ // until the exit transition finishes), so the removal is asynchronous.
160
+ await waitFor(() =>
161
+ expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
162
+ );
163
+ },
164
+ };
165
+
166
+ /** Escape closes the palette and returns focus to the textarea. */
167
+ export const CloseWithEscape: Story = {
168
+ render: () => <Field />,
169
+ play: async ({ canvasElement }) => {
170
+ const canvas = within(canvasElement);
171
+ const field = canvas.getByRole("textbox", { name: "Message" });
172
+ await userEvent.click(field);
173
+ await userEvent.keyboard("/h");
174
+ await userEvent.keyboard("{Escape}");
175
+
176
+ // See SelectWithEnter: the exit animation delays the actual unmount.
177
+ await waitFor(() =>
178
+ expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
179
+ );
180
+ await expect(field).toHaveFocus();
181
+ await expect(field).not.toHaveAttribute("aria-activedescendant");
182
+ },
183
+ };
184
+
185
+ /**
186
+ * No commands match the typed query — the empty state renders in the
187
+ * listbox. Ends by dismissing the palette: `cmdk`'s empty state is a bare
188
+ * `role="presentation"` node with no `option`/`group` children, which is a
189
+ * pre-existing `aria-required-children` shape shared by every cmdk-based
190
+ * combobox in this repo (already baselined for `overlays-command--groups`)
191
+ * — closing the palette before the story's axe pass avoids re-asserting that
192
+ * same, already-accepted primitive limitation here too.
193
+ */
194
+ export const NoMatches: Story = {
195
+ render: () => <Field />,
196
+ play: async ({ canvasElement }) => {
197
+ const canvas = within(canvasElement);
198
+ const field = canvas.getByRole("textbox", { name: "Message" });
199
+ await userEvent.click(field);
200
+ await userEvent.keyboard("/zzzz");
201
+ await expect(within(document.body).queryAllByRole("option")).toHaveLength(0);
202
+ await expect(
203
+ document.querySelector('[data-slot="prompt-input-slash-empty"]'),
204
+ ).toBeInTheDocument();
205
+
206
+ await userEvent.keyboard("{Escape}");
207
+ await waitFor(() =>
208
+ expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
209
+ );
210
+ },
211
+ };
@@ -0,0 +1,262 @@
1
+ import { useRef, useState } from "react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { type SlashCommand } from "@elabs-ai/components-ui";
6
+ import { PromptInput } from "./prompt-input";
7
+ import { PromptInputSlash, PromptInputSlashTextarea } from "./prompt-input-slash";
8
+
9
+ // jsdom does not implement `Element.prototype.scrollIntoView`, and `cmdk`
10
+ // calls it unconditionally (not feature-detected — it's a third-party
11
+ // dependency, not brand-ui code) whenever the highlighted item changes.
12
+ // `packages/ui/vitest.setup.ts` deliberately does NOT stub this globally, so —
13
+ // mirroring `command.test.tsx`'s local stub for the same jsdom gap — every
14
+ // test in this file gets a local no-op stub instead.
15
+ let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
16
+
17
+ beforeEach(() => {
18
+ originalScrollIntoView = Element.prototype.scrollIntoView;
19
+ Element.prototype.scrollIntoView = vi.fn();
20
+ });
21
+
22
+ afterEach(() => {
23
+ Element.prototype.scrollIntoView = originalScrollIntoView;
24
+ });
25
+
26
+ const COMMANDS: SlashCommand[] = [
27
+ { name: "help", description: "Show help" },
28
+ { name: "history", description: "Show history" },
29
+ { name: "hide", description: "Hide the panel" },
30
+ { name: "home", description: "Go home" },
31
+ { name: "harvest", description: "Harvest data" },
32
+ ];
33
+
34
+ function Harness({
35
+ commands = COMMANDS,
36
+ onSelect,
37
+ onValueChangeSpy,
38
+ }: {
39
+ commands?: SlashCommand[];
40
+ onSelect?: (command: SlashCommand) => void;
41
+ onValueChangeSpy?: (next: { text: string; caret: number }) => void;
42
+ }) {
43
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
44
+ const [text, setText] = useState("");
45
+ return (
46
+ <PromptInput onSubmit={vi.fn()}>
47
+ <PromptInputSlash
48
+ commands={commands}
49
+ value={text}
50
+ textareaRef={textareaRef}
51
+ onValueChange={(next) => {
52
+ setText(next.text);
53
+ onValueChangeSpy?.(next);
54
+ }}
55
+ onSelect={onSelect}
56
+ >
57
+ <PromptInputSlashTextarea
58
+ aria-label="Message"
59
+ value={text}
60
+ onChange={(event) => setText(event.target.value)}
61
+ />
62
+ </PromptInputSlash>
63
+ </PromptInput>
64
+ );
65
+ }
66
+
67
+ const field = () => screen.getByRole("textbox", { name: "Message" }) as HTMLTextAreaElement;
68
+
69
+ describe("trigger — line-start boundary", () => {
70
+ it("opens the palette when '/' is typed at the start of an empty line", async () => {
71
+ const user = userEvent.setup();
72
+ render(<Harness />);
73
+ await user.type(field(), "/h");
74
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
75
+ expect(screen.getAllByRole("option")).toHaveLength(5);
76
+ });
77
+
78
+ it("does not open when '/' is typed mid-word", async () => {
79
+ const user = userEvent.setup();
80
+ render(<Harness />);
81
+ await user.type(field(), "cd /usr");
82
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
83
+ });
84
+
85
+ it("opens again on a new line after a newline", async () => {
86
+ const user = userEvent.setup();
87
+ render(<Harness />);
88
+ await user.type(field(), "hello{Shift>}{Enter}{/Shift}/h");
89
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
90
+ });
91
+ });
92
+
93
+ describe("filtering", () => {
94
+ it("matches case-insensitively by prefix on the command name", async () => {
95
+ const user = userEvent.setup();
96
+ render(<Harness />);
97
+ // "history" and "hide" both start with "hi" — "hid" narrows to "hide" only.
98
+ await user.type(field(), "/HID");
99
+ const options = screen.getAllByRole("option");
100
+ expect(options).toHaveLength(1);
101
+ expect(options[0]).toHaveTextContent("hide");
102
+ });
103
+
104
+ it("shows the empty state when nothing matches", async () => {
105
+ const user = userEvent.setup();
106
+ render(<Harness />);
107
+ await user.type(field(), "/zzzz");
108
+ expect(screen.queryAllByRole("option")).toHaveLength(0);
109
+ // The locale key isn't registered in this test environment, so `t()`
110
+ // falls back to the raw key — assert the empty slot renders at all
111
+ // rather than coupling to English copy this component doesn't own.
112
+ // Assert the real English default, not the data-slot: the key now ships in
113
+ // `packages/ui/src/components/locale-provider/messages.ts`, so this proves
114
+ // the central catalogue carries it rather than that a node with the right
115
+ // attribute exists.
116
+ expect(document.querySelector('[data-slot="prompt-input-slash-empty"]')).toHaveTextContent(
117
+ "No matching commands.",
118
+ );
119
+ });
120
+ });
121
+
122
+ describe("active-index clamp (the narrowing-list bug)", () => {
123
+ it("clamps the active option into range when typing narrows the list from 5 to 1", async () => {
124
+ const user = userEvent.setup();
125
+ render(<Harness />);
126
+ await user.type(field(), "/h");
127
+ expect(screen.getAllByRole("option")).toHaveLength(5);
128
+
129
+ // Move the highlight to the LAST of the 5 matches (index 4).
130
+ await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}");
131
+ const beforeActive = screen
132
+ .getAllByRole("option")
133
+ .find((o) => o.getAttribute("aria-selected") === "true");
134
+ expect(beforeActive).toHaveTextContent("harvest");
135
+
136
+ // Narrow the list to the single command that still matches.
137
+ await user.type(field(), "a");
138
+ const options = screen.getAllByRole("option");
139
+ expect(options).toHaveLength(1);
140
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
141
+
142
+ // The textarea's aria-activedescendant must point at THAT option, never a
143
+ // stale id from the shrunk-away list.
144
+ expect(field()).toHaveAttribute("aria-activedescendant", options[0]!.id);
145
+ });
146
+
147
+ it("clamps when the `commands` prop itself shrinks, independent of the query", async () => {
148
+ const user = userEvent.setup();
149
+ // Re-render with a shrunk `commands` prop directly (rather than an
150
+ // outside click) — a real Popover legitimately dismisses on an outside
151
+ // pointerdown, which would test that behavior instead of the clamp.
152
+ const { rerender } = render(<Harness commands={COMMANDS} />);
153
+ await user.type(field(), "/h");
154
+ await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}");
155
+ expect(
156
+ screen.getAllByRole("option").find((o) => o.getAttribute("aria-selected") === "true"),
157
+ ).toHaveTextContent("harvest");
158
+
159
+ const harvest = COMMANDS.find((c) => c.name === "harvest");
160
+ if (!harvest) throw new Error("fixture missing 'harvest'");
161
+ rerender(<Harness commands={[harvest]} />);
162
+
163
+ const options = screen.getAllByRole("option");
164
+ expect(options).toHaveLength(1);
165
+ expect(options[0]).toHaveAttribute("aria-selected", "true");
166
+ expect(field()).toHaveAttribute("aria-activedescendant", options[0]!.id);
167
+ });
168
+ });
169
+
170
+ describe("keyboard navigation", () => {
171
+ it("wraps ArrowDown/ArrowUp at both ends", async () => {
172
+ const user = userEvent.setup();
173
+ render(<Harness commands={COMMANDS.slice(0, 2)} />);
174
+ await user.type(field(), "/h");
175
+ const active = () =>
176
+ screen.getAllByRole("option").find((o) => o.getAttribute("aria-selected") === "true");
177
+ expect(active()).toHaveTextContent("help");
178
+
179
+ await user.keyboard("{ArrowUp}"); // wraps to the last item
180
+ expect(active()).toHaveTextContent("history");
181
+
182
+ await user.keyboard("{ArrowDown}"); // wraps back to the first
183
+ expect(active()).toHaveTextContent("help");
184
+ });
185
+
186
+ it("selects the active command on Enter and inserts it into the field", async () => {
187
+ const onSelect = vi.fn();
188
+ const user = userEvent.setup();
189
+ render(<Harness commands={[{ name: "help" }]} onSelect={onSelect} />);
190
+ await user.type(field(), "/h");
191
+ await user.keyboard("{Enter}");
192
+
193
+ expect(onSelect).toHaveBeenCalledWith({ name: "help" });
194
+ expect(field()).toHaveValue("/help ");
195
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
196
+ });
197
+
198
+ it("does not submit the form on the Enter that selects a command", async () => {
199
+ const onSubmit = vi.fn();
200
+ const user = userEvent.setup();
201
+ function Submitting() {
202
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
203
+ const [text, setText] = useState("");
204
+ return (
205
+ <PromptInput onSubmit={onSubmit}>
206
+ <PromptInputSlash
207
+ commands={[{ name: "help" }]}
208
+ value={text}
209
+ textareaRef={textareaRef}
210
+ onValueChange={(next) => setText(next.text)}
211
+ >
212
+ <PromptInputSlashTextarea
213
+ aria-label="Message"
214
+ value={text}
215
+ onChange={(event) => setText(event.target.value)}
216
+ />
217
+ </PromptInputSlash>
218
+ </PromptInput>
219
+ );
220
+ }
221
+ render(<Submitting />);
222
+ await user.type(field(), "/h");
223
+ await user.keyboard("{Enter}");
224
+ expect(onSubmit).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it("closes on Escape and returns focus to the textarea", async () => {
228
+ const user = userEvent.setup();
229
+ render(<Harness />);
230
+ await user.type(field(), "/h");
231
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
232
+
233
+ await user.keyboard("{Escape}");
234
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
235
+ expect(field()).toHaveFocus();
236
+ });
237
+ });
238
+
239
+ describe("accessibility", () => {
240
+ it("renders a listbox of options and wires aria-activedescendant on the textarea", async () => {
241
+ const user = userEvent.setup();
242
+ render(<Harness />);
243
+ await user.type(field(), "/h");
244
+ const listbox = screen.getByRole("listbox");
245
+ const options = screen.getAllByRole("option");
246
+ expect(options.length).toBeGreaterThan(0);
247
+
248
+ const active = options.find((o) => o.getAttribute("aria-selected") === "true");
249
+ expect(active).toBeDefined();
250
+ expect(field()).toHaveAttribute("aria-activedescendant", active!.id);
251
+ expect(field()).toHaveAttribute("aria-controls", listbox.id);
252
+ });
253
+
254
+ it("drops aria-activedescendant and aria-controls once the palette closes", async () => {
255
+ const user = userEvent.setup();
256
+ render(<Harness />);
257
+ await user.type(field(), "/h");
258
+ await user.keyboard("{Escape}");
259
+ expect(field()).not.toHaveAttribute("aria-activedescendant");
260
+ expect(field()).not.toHaveAttribute("aria-controls");
261
+ });
262
+ });