diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx
index f8da0c5b..e1c72e01 100644
--- a/e2e/fixtures.tsx
+++ b/e2e/fixtures.tsx
@@ -35,6 +35,14 @@ import {
PanelHandle,
PanelHeader,
PanelPane,
+ PromptInputToolbar,
+ PromptInputAddMenu,
+ PromptInputButton,
+ PromptInputModelSelect,
+ PromptInputModeSelect,
+ PromptInputFloat,
+ PromptInputContext,
+ PromptInputNote,
} from "@corbits/react-ui";
import {
CommandPalette,
@@ -683,6 +691,169 @@ function PanelScenario() {
);
}
+/** Every toolbar part, with each choice and action recorded. "Toggle
+ * toolbar" takes the toolbar out, to compare layouts; clearing the reply
+ * chip removes it and "Reply to Ada" brings it back. "Open models" opens the
+ * model list the way a host would, without focusing it. */
+function PromptToolbarScenario() {
+ const [value, setValue] = useState("");
+ const [model, setModel] = useState("deep");
+ const [modelOpen, setModelOpen] = useState(false);
+ const [mode, setMode] = useState("ask");
+ const [toolbar, setToolbar] = useState(true);
+ const [replying, setReplying] = useState(true);
+ const { log, record } = useEventLog();
+ return (
+
+
record(`submit:${value}`)}
+ tokens={PROMPT_TOKENS}
+ >
+ {replying ? (
+
+ setReplying(false)}
+ />
+
+ ) : null}
+
+
+ {toolbar ? (
+
+ record("add:files"),
+ },
+ {
+ id: "link",
+ label: "Add a link",
+ disabled: true,
+ onSelect: () => record("add:link"),
+ },
+ {
+ id: "mention",
+ label: "Mention an agent",
+ onSelect: () => record("add:mention"),
+ },
+ ]}
+ />
+ {
+ record(`mode:${id}`);
+ setMode(id);
+ }}
+ />
+ {
+ record(`model:${id}`);
+ setModel(id);
+ }}
+ open={modelOpen}
+ onOpenChange={setModelOpen}
+ />
+ record("voice")}
+ >
+ V
+
+
+
+ ) : (
+
+ )}
+
+
Agents can make mistakes.
+
+
+
+ {log}
+
+ );
+}
+
+/** The toolbar inside an open Radix dialog, which records when it closes.
+ * "Open models" opens the model list the way a host would. */
+function PromptToolbarInDialog() {
+ const [value, setValue] = useState("");
+ const [modelOpen, setModelOpen] = useState(false);
+ const [open, setOpen] = useState(true);
+ const { log, record } = useEventLog();
+ return (
+ <>
+
+ {log}
+ >
+ );
+}
+
export const scenarios: Record ReactNode> = {
smoke: () => ,
"approval-card": () => ,
@@ -722,4 +893,6 @@ export const scenarios: Record ReactNode> = {
"prompt-input-chat": () => ,
"prompt-input-chat-no-stop": () => ,
panel: () => ,
+ "prompt-input-toolbar": () => ,
+ "prompt-input-toolbar-dialog": () => ,
};
diff --git a/e2e/prompt-input-toolbar.spec.ts b/e2e/prompt-input-toolbar.spec.ts
new file mode 100644
index 00000000..6c5aa241
--- /dev/null
+++ b/e2e/prompt-input-toolbar.spec.ts
@@ -0,0 +1,290 @@
+import { expect, test, type Page } from "@playwright/test";
+
+const events = (page: Page) => page.getByRole("status", { name: "Events" });
+const message = (page: Page) => page.getByRole("textbox", { name: "Message" });
+const model = (page: Page) => page.getByRole("combobox", { name: "Model" });
+const mode = (page: Page) =>
+ page.getByRole("combobox", { name: "Mode", exact: true });
+const add = (page: Page) =>
+ page.getByRole("button", { name: "Add", exact: true });
+
+function box(page: Page, selector: string) {
+ return page.locator(selector).evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return { top: rect.top, bottom: rect.bottom, height: rect.height };
+ });
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.goto("/prompt-input-toolbar");
+});
+
+test("the model picker opens, moves and chooses by keyboard, keeping focus on its trigger", async ({
+ page,
+}) => {
+ await model(page).focus();
+ await page.keyboard.press("Enter");
+ const list = page.getByRole("listbox", { name: "Model" });
+ await expect(list).toBeVisible();
+ await expect(model(page)).toBeFocused();
+ // Opens on the chosen model, then walks the grouped order upward.
+ const current = page.locator(
+ "[data-slot='prompt-input-select-option'][data-active]",
+ );
+ await expect(current).toHaveText("Deep 4");
+ await page.keyboard.press("ArrowUp");
+ await expect(current).toHaveText("Lite 1");
+ await expect(model(page)).toHaveAttribute(
+ "aria-activedescendant",
+ (await current.getAttribute("id")) ?? "",
+ );
+ await page.keyboard.press("Enter");
+ await expect(list).toBeHidden();
+ await expect(events(page)).toHaveText("model:lite");
+ await expect(model(page)).toHaveText("Lite");
+ await expect(model(page)).toBeFocused();
+});
+
+test("Escape closes a picker without choosing and leaves focus on its trigger", async ({
+ page,
+}) => {
+ await mode(page).focus();
+ await page.keyboard.press("ArrowDown");
+ await expect(
+ page.getByRole("listbox", { name: "Mode", exact: true }),
+ ).toBeVisible();
+ await page.keyboard.press("End");
+ await page.keyboard.press("Escape");
+ await expect(
+ page.getByRole("listbox", { name: "Mode", exact: true }),
+ ).toBeHidden();
+ await expect(mode(page)).toBeFocused();
+ await expect(mode(page)).toHaveText("Ask");
+ await expect(events(page)).toHaveText("");
+
+ // Space opens and chooses too.
+ await page.keyboard.press(" ");
+ await page.keyboard.press("ArrowDown");
+ await page.keyboard.press(" ");
+ await expect(events(page)).toHaveText("mode:plan");
+ await expect(
+ page.getByRole("listbox", { name: "Mode", exact: true }),
+ ).toBeHidden();
+ await expect(mode(page)).toBeFocused();
+});
+
+test("a picker chooses by pointer and closes on a press outside", async ({
+ page,
+}) => {
+ await model(page).click();
+ await page.getByRole("option", { name: "Swift 2" }).click();
+ await expect(events(page)).toHaveText("model:swift");
+ await expect(model(page)).toHaveText("Swift");
+
+ await model(page).click();
+ await expect(page.getByRole("listbox", { name: "Model" })).toBeVisible();
+ await page.getByRole("button", { name: "Reply to Ada" }).click();
+ await expect(page.getByRole("listbox", { name: "Model" })).toBeHidden();
+});
+
+test("the add menu opens, moves past a disabled item and chooses by keyboard", async ({
+ page,
+}) => {
+ await add(page).focus();
+ await page.keyboard.press("Enter");
+ const menu = page.getByRole("menu", { name: "Add" });
+ await expect(menu).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await expect(
+ page.getByRole("menuitem", { name: "Mention an agent" }),
+ ).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
+ await page.keyboard.press("ArrowUp");
+ await page.keyboard.press("Enter");
+ await expect(menu).toBeHidden();
+ await expect(events(page)).toHaveText("add:mention");
+ await expect(add(page)).toBeFocused();
+});
+
+test("Escape closes the add menu and returns focus; Tab closes it and moves on", async ({
+ page,
+}) => {
+ await add(page).focus();
+ await page.keyboard.press("ArrowUp");
+ await expect(
+ page.getByRole("menuitem", { name: "Mention an agent" }),
+ ).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("menu", { name: "Add" })).toBeHidden();
+ await expect(add(page)).toBeFocused();
+
+ await page.keyboard.press("ArrowDown");
+ await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
+ await page.keyboard.press("Tab");
+ await expect(page.getByRole("menu", { name: "Add" })).toBeHidden();
+ await expect(mode(page)).toBeFocused();
+ await expect(events(page)).toHaveText("");
+});
+
+test("a press between rows of an open popup leaves its keys working", async ({
+ page,
+}) => {
+ await model(page).focus();
+ await page.keyboard.press("Enter");
+ const current = page.locator(
+ "[data-slot='prompt-input-select-option'][data-active]",
+ );
+ await expect(current).toHaveText("Deep 4");
+ await page.getByText("Fast", { exact: true }).click();
+ await expect(model(page)).toBeFocused();
+ await page.keyboard.press("ArrowUp");
+ await expect(current).toHaveText("Lite 1");
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("listbox", { name: "Model" })).toBeHidden();
+ await expect(model(page)).toBeFocused();
+
+ await add(page).focus();
+ await page.keyboard.press("Enter");
+ const menu = page.getByRole("menu", { name: "Add" });
+ await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
+ // The menu's padding, above its first item.
+ await menu.click({ position: { x: 8, y: 2 } });
+ await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await expect(
+ page.getByRole("menuitem", { name: "Mention an agent" }),
+ ).toBeFocused();
+ await expect(events(page)).toHaveText("");
+});
+
+test("Escape in the textarea closes a list the host opened, after the suggestion list", async ({
+ page,
+}) => {
+ await page.getByRole("button", { name: "Open models" }).click();
+ const models = page.getByRole("listbox", { name: "Model" });
+ await expect(models).toBeVisible();
+ await message(page).focus();
+ await message(page).pressSequentially("@a");
+ const mentions = page.getByRole("listbox", { name: "Mentions" });
+ await expect(mentions).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(mentions).toBeHidden();
+ await expect(models).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(models).toBeHidden();
+ await expect(model(page)).toHaveAttribute("aria-expanded", "false");
+ await expect(message(page)).toBeFocused();
+ await expect(events(page)).toHaveText("");
+});
+
+test("the toolbar takes its own row, and without it the box is one row", async ({
+ page,
+}) => {
+ const field = await box(page, "[data-slot='prompt-input-field']");
+ const toolbar = await box(page, "[data-slot='prompt-input-toolbar']");
+ expect(toolbar.top).toBeGreaterThanOrEqual(field.bottom);
+
+ await page.getByRole("button", { name: "Toggle toolbar" }).click();
+ await expect(page.locator("[data-slot='prompt-input-toolbar']")).toHaveCount(
+ 0,
+ );
+ const form = await box(page, "[data-slot='prompt-input']");
+ const single = await box(page, "[data-slot='prompt-input-field']");
+ const submit = await box(page, "[data-slot='prompt-input-submit']");
+ // One row: the send button sits beside the text, not under it.
+ expect(submit.top).toBeLessThan(single.bottom);
+ expect(form.height).toBeLessThan(field.height + toolbar.height);
+});
+
+test("the float holds the reply chip above the box and steps aside for a popup", async ({
+ page,
+}) => {
+ const float = page.locator("[data-slot='prompt-input-float']");
+ const chip = page.locator("[data-slot='prompt-input-context']");
+ await expect(chip).toContainText("Replying to Ada");
+ const floatBox = await box(page, "[data-slot='prompt-input-float']");
+ const form = await box(page, "[data-slot='prompt-input']");
+ expect(floatBox.bottom).toBeLessThanOrEqual(form.top);
+ await expect(float).toHaveCSS("visibility", "visible");
+
+ await model(page).click();
+ await expect(float).toHaveCSS("visibility", "hidden");
+ await page.keyboard.press("Escape");
+ await expect(float).toHaveCSS("visibility", "visible");
+
+ await message(page).pressSequentially("@a");
+ await expect(page.getByRole("listbox", { name: "Mentions" })).toBeVisible();
+ await expect(float).toHaveCSS("visibility", "hidden");
+ await page.keyboard.press("Escape");
+ await expect(float).toHaveCSS("visibility", "visible");
+
+ await page.getByRole("button", { name: "Clear" }).click();
+ await expect(chip).toHaveCount(0);
+});
+
+test("toolbar buttons never submit the prompt", async ({ page }) => {
+ await message(page).fill("draft");
+ await page.getByRole("button", { name: "Voice" }).click();
+ await expect(events(page)).toHaveText("voice");
+ await expect(message(page)).toHaveValue("draft");
+});
+
+test.describe("inside a dialog", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/prompt-input-toolbar-dialog");
+ });
+
+ test("Escape in the add menu closes the menu, not the dialog", async ({
+ page,
+ }) => {
+ const dialog = page.getByRole("dialog", { name: "Ask" });
+ await add(page).click();
+ const menu = page.getByRole("menu", { name: "Add" });
+ await expect(
+ page.getByRole("menuitem", { name: "Add files" }),
+ ).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(menu).toBeHidden();
+ await expect(add(page)).toBeFocused();
+ await expect(dialog).toBeVisible();
+
+ await model(page).focus();
+ await page.keyboard.press("Enter");
+ const list = page.getByRole("listbox", { name: "Model" });
+ await expect(list).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(list).toBeHidden();
+ await expect(model(page)).toBeFocused();
+ await expect(dialog).toBeVisible();
+ });
+
+ test("beside the suggestion list, Escape closes the list, then a host-opened popup, then the dialog", async ({
+ page,
+ }) => {
+ const dialog = page.getByRole("dialog", { name: "Ask" });
+ const models = page.getByRole("listbox", { name: "Model" });
+ const mentions = page.getByRole("listbox", { name: "Mentions" });
+ await page.getByRole("button", { name: "Open models" }).click();
+ await expect(models).toBeVisible();
+ await message(page).pressSequentially("@a");
+ await expect(mentions).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(mentions).toBeHidden();
+ await expect(models).toBeVisible();
+ await expect(dialog).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(models).toBeHidden();
+ await expect(message(page)).toBeFocused();
+ await expect(dialog).toBeVisible();
+
+ await page.keyboard.press("Escape");
+ await expect(dialog).toBeHidden();
+ await expect(events(page)).toHaveText("dialog-closed");
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index dc769c66..283451cd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -133,3 +133,4 @@ export * from "./ui/conversation.js";
export * from "./ui/conversation-divider.js";
export * from "./ui/prompt-input.js";
export * from "./ui/panel.js";
+export * from "./ui/prompt-input-toolbar.js";
diff --git a/src/ui/prompt-input-toolbar.test.tsx b/src/ui/prompt-input-toolbar.test.tsx
new file mode 100644
index 00000000..a1059202
--- /dev/null
+++ b/src/ui/prompt-input-toolbar.test.tsx
@@ -0,0 +1,1089 @@
+import { describe, expect, spyOn, test } from "bun:test";
+import { act, createRef, type ReactNode, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+
+import type { PromptToken } from "../lib/prompt-tokens.js";
+import { Dialog, DialogContent, DialogTitle } from "./dialog.js";
+import {
+ PromptInput,
+ PromptInputSubmit,
+ PromptInputSuggestions,
+ PromptInputTextarea,
+} from "./prompt-input.js";
+import {
+ PromptInputAddMenu,
+ PromptInputButton,
+ PromptInputContext,
+ PromptInputFloat,
+ PromptInputModeSelect,
+ PromptInputModelSelect,
+ PromptInputNote,
+ PromptInputToolbar,
+ type PromptInputAddMenuItem,
+ type PromptInputModelOption,
+ type PromptInputModeOption,
+} from "./prompt-input-toolbar.js";
+
+const MODELS: readonly PromptInputModelOption[] = [
+ { id: "swift", name: "Swift 2", short: "Swift", group: "Fast" },
+ { id: "deep", name: "Deep 4", short: "Deep", group: "Thorough" },
+ { id: "lite", name: "Lite 1", group: "Fast" },
+];
+
+const MODES: readonly PromptInputModeOption[] = [
+ { id: "ask", label: "Ask", detail: "Answers without acting" },
+ { id: "agent", label: "Agent", detail: "Acts on your behalf" },
+];
+
+function mount(node: ReactNode) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let root!: Root;
+ act(() => {
+ root = createRoot(container);
+ root.render(node);
+ });
+ return {
+ container,
+ query(selector: string): T {
+ const element = container.querySelector(selector);
+ if (element === null) throw new Error(`no ${selector}`);
+ return element;
+ },
+ all(selector: string): HTMLElement[] {
+ return [...container.querySelectorAll(selector)];
+ },
+ rerender(next: ReactNode) {
+ act(() => root.render(next));
+ },
+ unmount() {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+function press(target: Element, key: string): KeyboardEvent {
+ const event = new KeyboardEvent("keydown", {
+ key,
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ target.dispatchEvent(event);
+ });
+ return event;
+}
+
+function click(target: HTMLElement) {
+ act(() => target.click());
+}
+
+/** Whether a press on `target` keeps the browser from moving focus. */
+function pressKeepsFocus(target: Element): boolean {
+ const event = new MouseEvent("mousedown", {
+ bubbles: true,
+ cancelable: true,
+ });
+ act(() => {
+ target.dispatchEvent(event);
+ });
+ return event.defaultPrevented;
+}
+
+function ControlledModel({
+ initial,
+ events,
+}: {
+ readonly initial: string;
+ readonly events: string[];
+}) {
+ const [value, setValue] = useState(initial);
+ return (
+ {
+ events.push(id);
+ setValue(id);
+ }}
+ />
+ );
+}
+
+describe("PromptInputModelSelect", () => {
+ test("shows the first model's short name by default and lists models by group", () => {
+ const view = mount();
+ const trigger = view.query("[role='combobox']");
+ expect(trigger.getAttribute("aria-label")).toBe("Model");
+ expect(trigger.textContent).toBe("Swift");
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(trigger.hasAttribute("aria-controls")).toBe(false);
+
+ click(trigger);
+ const list = view.query("[role='listbox']");
+ expect(trigger.getAttribute("aria-controls")).toBe(list.id);
+ const groups = view.all("[role='group']");
+ expect(
+ groups.map((group) => {
+ const labelId = group.getAttribute("aria-labelledby") ?? "";
+ return document.getElementById(labelId)?.textContent;
+ }),
+ ).toEqual(["Fast", "Thorough"]);
+ // Grouped order, which is also the order the arrows walk.
+ expect(
+ view.all("[role='option']").map((option) => option.textContent),
+ ).toEqual(["Swift 2", "Lite 1", "Deep 4"]);
+ view.unmount();
+ });
+
+ test("a press anywhere in the open list keeps focus on the trigger", () => {
+ const view = mount();
+ click(view.query("[role='combobox']"));
+ const list = view.query("[role='listbox']");
+ const heading = document.getElementById(
+ view.query("[role='group']").getAttribute("aria-labelledby") ?? "",
+ );
+ if (heading === null) throw new Error("no heading");
+ expect(pressKeepsFocus(list)).toBe(true);
+ expect(pressKeepsFocus(heading)).toBe(true);
+ expect(pressKeepsFocus(view.query("[role='option']"))).toBe(true);
+ view.unmount();
+ });
+
+ test("calls onValueChange with the chosen id and reflects the controlled value", () => {
+ const events: string[] = [];
+ const view = mount();
+ const trigger = view.query("[role='combobox']");
+ expect(trigger.textContent).toBe("Deep");
+
+ click(trigger);
+ const chosen = view
+ .all("[role='option']")
+ .find((option) => option.getAttribute("aria-selected") === "true");
+ expect(chosen?.textContent).toBe("Deep 4");
+ const lite = view.all("[role='option']")[1];
+ if (lite === undefined) throw new Error("no option");
+ click(lite);
+ expect(events).toEqual(["lite"]);
+ expect(trigger.textContent).toBe("Lite 1");
+ expect(view.container.querySelector("[role='listbox']")).toBeNull();
+ view.unmount();
+ });
+
+ test("a controlled value the host does not change stays put", () => {
+ const events: string[] = [];
+ const view = mount(
+ events.push(id)}
+ />,
+ );
+ const trigger = view.query("[role='combobox']");
+ click(trigger);
+ const deep = view.all("[role='option']")[2];
+ if (deep === undefined) throw new Error("no option");
+ click(deep);
+ expect(events).toEqual(["deep"]);
+ expect(trigger.textContent).toBe("Swift");
+ view.unmount();
+ });
+
+ test("works by keyboard with focus kept on the trigger", () => {
+ const events: string[] = [];
+ const view = mount();
+ const trigger = view.query("[role='combobox']");
+ act(() => trigger.focus());
+
+ const opening = press(trigger, "ArrowDown");
+ expect(opening.defaultPrevented).toBe(true);
+ const options = view.all("[role='option']");
+ // Opens on the chosen model.
+ expect(trigger.getAttribute("aria-activedescendant")).toBe(
+ options[1]?.id ?? "",
+ );
+ press(trigger, "ArrowDown");
+ expect(trigger.getAttribute("aria-activedescendant")).toBe(
+ options[2]?.id ?? "",
+ );
+ // Stops at the end rather than wrapping.
+ press(trigger, "ArrowDown");
+ expect(trigger.getAttribute("aria-activedescendant")).toBe(
+ options[2]?.id ?? "",
+ );
+ press(trigger, "Home");
+ expect(trigger.getAttribute("aria-activedescendant")).toBe(
+ options[0]?.id ?? "",
+ );
+ press(trigger, "Enter");
+ expect(events).toEqual(["swift"]);
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(trigger.hasAttribute("aria-activedescendant")).toBe(false);
+ expect(document.activeElement).toBe(trigger);
+ view.unmount();
+ });
+
+ test("Escape closes without choosing and does not reach an enclosing handler", () => {
+ const events: string[] = [];
+ const outer: string[] = [];
+ const view = mount(
+ outer.push(event.key)}>
+
events.push(id)}
+ />
+ ,
+ );
+ const trigger = view.query("[role='combobox']");
+ act(() => trigger.focus());
+ press(trigger, "Enter");
+ press(trigger, "ArrowDown");
+ press(trigger, "Escape");
+ expect(view.container.querySelector("[role='listbox']")).toBeNull();
+ expect(events).toEqual([]);
+ expect(outer).toEqual(["Enter", "ArrowDown"]);
+ expect(document.activeElement).toBe(trigger);
+ view.unmount();
+ });
+
+ test("a value that matches no model shows the label", () => {
+ const view = mount(
+ undefined}
+ />,
+ );
+ expect(view.query("[role='combobox']").textContent).toBe("Model");
+ view.unmount();
+ });
+
+ test("renders nothing without models", () => {
+ const view = mount();
+ expect(view.container.innerHTML).toBe("");
+ view.unmount();
+ });
+
+ test("an uncontrolled select whose models arrive after mount defaults to the first", () => {
+ const events: string[] = [];
+ let setModels!: (models: readonly PromptInputModelOption[]) => void;
+ function Host() {
+ const [models, set] = useState([]);
+ setModels = set;
+ return (
+ events.push(id)}
+ />
+ );
+ }
+ const view = mount();
+ act(() => setModels(MODELS));
+ const trigger = view.query("[role='combobox']");
+ expect(trigger.textContent).toBe("Swift");
+ click(trigger);
+ expect(
+ view
+ .all("[role='option']")
+ .filter((option) => option.getAttribute("aria-selected") === "true")
+ .map((option) => option.textContent),
+ ).toEqual(["Swift 2"]);
+ // The late default is not a choice, so the host hears nothing.
+ expect(events).toEqual([]);
+
+ // A choice the user made survives a new list.
+ const deep = view.all("[role='option']")[2];
+ if (deep === undefined) throw new Error("no option");
+ click(deep);
+ act(() => setModels([...MODELS].reverse()));
+ expect(trigger.textContent).toBe("Deep");
+ expect(events).toEqual(["deep"]);
+ view.unmount();
+ });
+
+ test("every open starts on the chosen model, even one the host drives", () => {
+ let setOpen!: (open: boolean) => void;
+ function Host() {
+ const [open, set] = useState(false);
+ setOpen = set;
+ return (
+
+ );
+ }
+ const view = mount();
+ const trigger = view.query("[role='combobox']");
+ act(() => trigger.focus());
+ press(trigger, "ArrowDown");
+ press(trigger, "ArrowDown");
+ press(trigger, "ArrowDown");
+ expect(view.query("[data-active]").textContent).toBe("Deep 4");
+ act(() => {
+ document.body.dispatchEvent(
+ new PointerEvent("pointerdown", { bubbles: true }),
+ );
+ });
+ expect(view.container.querySelector("[role='listbox']")).toBeNull();
+
+ act(() => setOpen(true));
+ const active = view.query("[data-active]");
+ expect(active.textContent).toBe("Swift 2");
+ expect(trigger.getAttribute("aria-activedescendant")).toBe(active.id);
+ view.unmount();
+ });
+
+ test("ids are unique across instances", () => {
+ const view = mount(
+ <>
+
+
+ >,
+ );
+ const ids = view.all("[id]").map((element) => element.id);
+ expect(ids.length).toBeGreaterThan(0);
+ expect(new Set(ids).size).toBe(ids.length);
+ view.unmount();
+ });
+});
+
+describe("PromptInputModeSelect", () => {
+ test("calls onValueChange with the chosen id and reflects the controlled value", () => {
+ function Host({ events }: { readonly events: string[] }) {
+ const [mode, setMode] = useState("agent");
+ return (
+ {
+ events.push(id);
+ setMode(id);
+ }}
+ />
+ );
+ }
+ const events: string[] = [];
+ const view = mount();
+ const trigger = view.query("[role='combobox']");
+ expect(trigger.getAttribute("aria-label")).toBe("Mode");
+ expect(trigger.textContent).toBe("Agent");
+
+ act(() => trigger.focus());
+ press(trigger, " ");
+ expect(
+ view.all("[role='option']").map((option) => option.textContent),
+ ).toEqual(["AskAnswers without acting", "AgentActs on your behalf"]);
+ press(trigger, "ArrowUp");
+ press(trigger, " ");
+ expect(events).toEqual(["ask"]);
+ expect(trigger.textContent).toBe("Ask");
+ view.unmount();
+ });
+
+ test("open state can be owned by the host", () => {
+ const changes: boolean[] = [];
+ const view = mount(
+ changes.push(open)}
+ />,
+ );
+ expect(view.container.querySelector("[role='listbox']")).not.toBeNull();
+ const option = view.all("[role='option']")[1];
+ if (option === undefined) throw new Error("no option");
+ click(option);
+ expect(changes).toEqual([false]);
+ // Still open: the host has not closed it.
+ expect(view.container.querySelector("[role='listbox']")).not.toBeNull();
+ view.unmount();
+ });
+});
+
+function menuItems(events: string[]): PromptInputAddMenuItem[] {
+ return [
+ {
+ id: "files",
+ label: "Add files",
+ onSelect: () => events.push(`files:${document.activeElement?.tagName}`),
+ },
+ {
+ id: "mention",
+ label: "Mention an agent",
+ disabled: true,
+ onSelect: () => events.push("mention"),
+ },
+ {
+ id: "command",
+ label: "Use a command",
+ detail: "Starts a /",
+ onSelect: () => events.push("command"),
+ },
+ ];
+}
+
+describe("PromptInputAddMenu", () => {
+ test("opens on the first item, moves past disabled items and wraps", () => {
+ const view = mount();
+ const trigger = view.query("[aria-haspopup='menu']");
+ expect(trigger.getAttribute("aria-label")).toBe("Add");
+ click(trigger);
+ const menu = view.query("[role='menu']");
+ expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
+ const items = view.all("[role='menuitem']");
+ expect(items[1]?.getAttribute("aria-disabled")).toBe("true");
+ expect(document.activeElement).toBe(items[0] ?? null);
+
+ press(menu, "ArrowDown");
+ expect(document.activeElement).toBe(items[2] ?? null);
+ press(menu, "ArrowDown");
+ expect(document.activeElement).toBe(items[0] ?? null);
+ press(menu, "ArrowUp");
+ expect(document.activeElement).toBe(items[2] ?? null);
+ press(menu, "Home");
+ expect(document.activeElement).toBe(items[0] ?? null);
+ view.unmount();
+ });
+
+ test("a press anywhere in the open menu keeps focus where it is", () => {
+ const view = mount();
+ click(view.query("[aria-haspopup='menu']"));
+ expect(pressKeepsFocus(view.query("[role='menu']"))).toBe(true);
+ expect(pressKeepsFocus(view.query("[role='menuitem']"))).toBe(true);
+ view.unmount();
+ });
+
+ test("ArrowUp on the trigger opens on the last item", () => {
+ const view = mount();
+ const trigger = view.query("[aria-haspopup='menu']");
+ act(() => trigger.focus());
+ press(trigger, "ArrowUp");
+ expect(document.activeElement?.textContent).toBe("Use a commandStarts a /");
+ view.unmount();
+ });
+
+ test("an ArrowUp the host declined does not make its later open take focus", () => {
+ const asks: boolean[] = [];
+ function Host() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ asks.push(next)}
+ />
+
+ >
+ );
+ }
+ const view = mount();
+ const trigger = view.query("[aria-haspopup='menu']");
+ act(() => trigger.focus());
+ press(trigger, "ArrowUp");
+ expect(asks).toEqual([true]);
+ expect(view.container.querySelector("[role='menu']")).toBeNull();
+ click(view.query("[data-host]"));
+ expect(view.container.querySelector("[role='menu']")).not.toBeNull();
+ expect(document.activeElement?.getAttribute("aria-haspopup")).toBe("menu");
+ view.unmount();
+ });
+
+ test("choosing an item closes the menu, returns focus, then runs it", () => {
+ const events: string[] = [];
+ const view = mount();
+ const trigger = view.query("[aria-haspopup='menu']");
+ click(trigger);
+ const files = view.all("[role='menuitem']")[0];
+ if (files === undefined) throw new Error("no item");
+ click(files);
+ // Focus was back on the trigger when the action ran.
+ expect(events).toEqual(["files:BUTTON"]);
+ expect(document.activeElement).toBe(trigger);
+ expect(view.container.querySelector("[role='menu']")).toBeNull();
+ view.unmount();
+ });
+
+ test("a disabled item does nothing", () => {
+ const events: string[] = [];
+ const view = mount();
+ click(view.query("[aria-haspopup='menu']"));
+ const mention = view.all("[role='menuitem']")[1];
+ if (mention === undefined) throw new Error("no item");
+ click(mention);
+ expect(events).toEqual([]);
+ expect(view.container.querySelector("[role='menu']")).not.toBeNull();
+ view.unmount();
+ });
+
+ test("Escape closes, returns focus and stays inside the menu", () => {
+ const outer: string[] = [];
+ const view = mount(
+ outer.push(event.key)}>
+
+
,
+ );
+ const trigger = view.query("[aria-haspopup='menu']");
+ click(trigger);
+ press(view.query("[role='menu']"), "Escape");
+ expect(view.container.querySelector("[role='menu']")).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ expect(outer).toEqual([]);
+ view.unmount();
+ });
+
+ test("a menu open from the start does not take focus", () => {
+ const outside = document.createElement("button");
+ document.body.appendChild(outside);
+ outside.focus();
+ const view = mount(
+ ,
+ );
+ expect(view.container.querySelector("[role='menu']")).not.toBeNull();
+ expect(document.activeElement).toBe(outside);
+ view.unmount();
+ outside.remove();
+ });
+
+ test("a menu whose items are all disabled opens, keeps focus and closes on Escape", () => {
+ const view = mount(
+ undefined },
+ ]}
+ />,
+ );
+ const trigger = view.query("[aria-haspopup='menu']");
+ act(() => trigger.focus());
+ press(trigger, "ArrowDown");
+ expect(view.container.querySelector("[role='menu']")).not.toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ press(trigger, "Escape");
+ expect(view.container.querySelector("[role='menu']")).toBeNull();
+ view.unmount();
+ });
+
+ test("unmounting while open removes its document listeners", () => {
+ const added = spyOn(document, "addEventListener");
+ const removed = spyOn(document, "removeEventListener");
+ try {
+ const view = mount(
+ ,
+ );
+ view.unmount();
+ const count = (calls: readonly (readonly unknown[])[], type: string) =>
+ calls.filter(([name]) => name === type).length;
+ expect(count(added.mock.calls, "pointerdown")).toBeGreaterThan(0);
+ expect(count(removed.mock.calls, "pointerdown")).toBe(
+ count(added.mock.calls, "pointerdown"),
+ );
+ } finally {
+ added.mockRestore();
+ removed.mockRestore();
+ }
+ });
+});
+
+describe("PromptInputButton", () => {
+ test("never submits the form it sits in", () => {
+ const submits: string[] = [];
+ const view = mount(
+ ,
+ );
+ const button = view.query(
+ "[data-slot='prompt-input-button']",
+ );
+ expect(button.type).toBe("button");
+ click(button);
+ expect(submits).toEqual([]);
+ view.unmount();
+ });
+});
+
+describe("refs", () => {
+ test("the add menu and both selects hand their root to a ref", () => {
+ const add = createRef();
+ const model = createRef();
+ const mode = createRef();
+ const view = mount(
+ <>
+
+
+
+ >,
+ );
+ expect(add.current).toBe(
+ view.query("[data-slot='prompt-input-add-menu']"),
+ );
+ expect(model.current).toBe(
+ view.query("[data-slot='prompt-input-model-select']"),
+ );
+ expect(mode.current).toBe(
+ view.query("[data-slot='prompt-input-mode-select']"),
+ );
+ view.unmount();
+ expect(add.current).toBeNull();
+ });
+});
+
+describe("PromptInputContext", () => {
+ test("offers a clear button only when it can be cleared", () => {
+ const cleared: string[] = [];
+ const plain = mount(
+ ,
+ );
+ expect(plain.container.querySelector("button")).toBeNull();
+ expect(plain.container.textContent).toBe("Replying to Ada");
+ plain.unmount();
+
+ const clearable = mount(
+ cleared.push("clear")}
+ />,
+ );
+ const button = clearable.query("button");
+ expect(button.getAttribute("aria-label")).toBe("Stop replying");
+ click(button);
+ expect(cleared).toEqual(["clear"]);
+ clearable.unmount();
+ });
+});
+
+describe("PromptInputToolbar, PromptInputFloat and PromptInputNote", () => {
+ test("each renders its slot, keeps the host's props and merges its class", () => {
+ const view = mount(
+ <>
+
+
+
+
+ Floating
+
+ Small print
+ >,
+ );
+ const toolbar = view.query("[data-slot='prompt-input-toolbar']");
+ expect(toolbar.tagName).toBe("DIV");
+ expect(toolbar.getAttribute("aria-label")).toBe("Message tools");
+ expect(toolbar.classList.contains("basis-full")).toBe(true);
+ expect(toolbar.classList.contains("host-a")).toBe(true);
+ expect(toolbar.textContent).toBe("Tool");
+
+ const float = view.query("[data-slot='prompt-input-float']");
+ expect(float.id).toBe("float");
+ expect(float.classList.contains("absolute")).toBe(true);
+ expect(float.classList.contains("host-b")).toBe(true);
+ expect(float.textContent).toBe("Floating");
+
+ const note = view.query("[data-slot='prompt-input-note']");
+ expect(note.tagName).toBe("P");
+ expect(note.classList.contains("host-c")).toBe(true);
+ expect(note.textContent).toBe("Small print");
+ view.unmount();
+ });
+});
+
+const TOKENS: readonly PromptToken[] = [
+ { id: "ada", kind: "mention", token: "@Ada" },
+];
+
+/** A prompt with a toolbar, as a host would put one together. */
+function Prompt({
+ open,
+ disabled = false,
+ partsDisabled,
+ events,
+}: {
+ readonly open?: "add" | "model";
+ readonly disabled?: boolean;
+ readonly partsDisabled?: boolean;
+ /** Records the popups' open changes and choices. */
+ readonly events?: string[];
+}) {
+ const [value, setValue] = useState("");
+ const own = partsDisabled === undefined ? {} : { disabled: partsDisabled };
+ return (
+ undefined}
+ tokens={TOKENS}
+ disabled={disabled}
+ >
+
+
+
+ events?.push(`add:${next}`)}
+ items={[
+ { id: "f", label: "Add files", onSelect: () => events?.push("f") },
+ ]}
+ />
+
+ events?.push(`model:${next}`)}
+ onValueChange={(id) => events?.push(`model=${id}`)}
+ />
+
+ v
+
+
+
+
+ );
+}
+
+/** A boolean rather than the element, so a failure prints a line, not a
+ * React tree. */
+function shown(view: { readonly container: HTMLElement }, selector: string) {
+ return view.container.querySelector(selector) !== null;
+}
+
+const POPUPS = {
+ add: { popup: "[role='menu']", trigger: "[aria-haspopup='menu']" },
+ model: {
+ popup: "[role='listbox']",
+ trigger: "[data-slot='prompt-input-model-select'] [role='combobox']",
+ },
+} as const;
+
+/** `Prompt` in a dialog that records its own open changes. The dialog
+ * renders in a portal, so its contents are queried from `document`. */
+function InDialog({
+ dialog,
+ open,
+}: {
+ readonly dialog: boolean[];
+ readonly open?: "add" | "model";
+}) {
+ const [isOpen, setOpen] = useState(true);
+ return (
+
+ );
+}
+
+/** A prompt in a dialog whose model list the host opens with a button. */
+function HostModelInDialog({ dialog }: { readonly dialog: boolean[] }) {
+ const [value, setValue] = useState("");
+ const [modelOpen, setModelOpen] = useState(false);
+ return (
+
+ );
+}
+
+function typeMention(textarea: HTMLTextAreaElement) {
+ const setValue = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ "value",
+ )?.set;
+ act(() => {
+ textarea.focus();
+ setValue?.call(textarea, "@");
+ textarea.setSelectionRange(1, 1);
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+
+function inDialog(selector: string): T {
+ const element = document.querySelector(`[role='dialog'] ${selector}`);
+ if (element === null) throw new Error(`no ${selector}`);
+ return element;
+}
+
+describe("toolbar parts inside PromptInput", () => {
+ for (const which of ["add", "model"] as const) {
+ test(`the ${which} popup, opened by the host, closes on Escape from the textarea and focus stays there`, () => {
+ const view = mount();
+ const textarea = view.query("textarea");
+ act(() => textarea.focus());
+ expect(shown(view, POPUPS[which].popup)).toBe(true);
+ press(textarea, "Escape");
+ expect(shown(view, POPUPS[which].popup)).toBe(false);
+ expect(
+ view.query(POPUPS[which].trigger).getAttribute("aria-expanded"),
+ ).toBe("false");
+ expect(document.activeElement).toBe(textarea);
+ view.unmount();
+ });
+ }
+
+ test("with the suggestion list open too, the first Escape closes only the list", () => {
+ const view = mount();
+ const textarea = view.query("textarea");
+ const setValue = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ "value",
+ )?.set;
+ act(() => {
+ textarea.focus();
+ setValue?.call(textarea, "@");
+ textarea.setSelectionRange(1, 1);
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const list = "[data-slot='prompt-input-suggestions']";
+ expect(shown(view, list)).toBe(true);
+ expect(shown(view, "[role='menu']")).toBe(true);
+
+ press(textarea, "Escape");
+ expect(shown(view, list)).toBe(false);
+ expect(shown(view, "[role='menu']")).toBe(true);
+
+ press(textarea, "Escape");
+ expect(shown(view, "[role='menu']")).toBe(false);
+ expect(document.activeElement).toBe(textarea);
+ view.unmount();
+ });
+
+ test("a popup's own Escape is not handled twice", () => {
+ const changes: boolean[] = [];
+ const view = mount(
+ changes.push(open)}
+ />,
+ );
+ const trigger = view.query("[aria-haspopup='menu']");
+ click(trigger);
+ press(view.query("[role='menu']"), "Escape");
+ expect(changes).toEqual([true, false]);
+ expect(document.activeElement).toBe(trigger);
+ view.unmount();
+ });
+
+ test("Escape inside the add menu closes the menu, not an enclosing dialog", () => {
+ const dialog: boolean[] = [];
+ const view = mount();
+ const trigger = inDialog("[aria-haspopup='menu']");
+ click(trigger);
+ const item = inDialog("[role='menuitem']");
+ expect(document.activeElement).toBe(item);
+ press(item, "Escape");
+ expect(dialog).toEqual([]);
+ expect(document.querySelector("[role='dialog'] [role='menu']")).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ view.unmount();
+ });
+
+ test("Escape on an open select closes the list, not an enclosing dialog", () => {
+ const dialog: boolean[] = [];
+ const view = mount();
+ const trigger = inDialog(POPUPS.model.trigger);
+ act(() => trigger.focus());
+ click(trigger);
+ expect(
+ document.querySelector("[role='dialog'] [role='listbox']"),
+ ).not.toBeNull();
+ press(trigger, "Escape");
+ expect(dialog).toEqual([]);
+ expect(
+ document.querySelector("[role='dialog'] [role='listbox']"),
+ ).toBeNull();
+ expect(document.activeElement).toBe(trigger);
+ view.unmount();
+ });
+
+ for (const which of ["add", "model"] as const) {
+ test(`Escape from the textarea closes the host-opened ${which} popup, not an enclosing dialog`, () => {
+ const dialog: boolean[] = [];
+ const view = mount();
+ const textarea = inDialog("textarea");
+ act(() => textarea.focus());
+ const popup = `[role='dialog'] ${POPUPS[which].popup}`;
+ expect(document.querySelector(popup)).not.toBeNull();
+ press(textarea, "Escape");
+ expect(dialog).toEqual([]);
+ expect(document.querySelector(popup)).toBeNull();
+ expect(document.activeElement).toBe(textarea);
+ view.unmount();
+ });
+ }
+
+ test("Escape in an unrelated dialog closes the dialog, not a host-opened list", () => {
+ const events: string[] = [];
+ const dialog: boolean[] = [];
+ function Page() {
+ const [isOpen, setOpen] = useState(true);
+ return (
+ <>
+
+
+ >
+ );
+ }
+ const view = mount();
+ const other = inDialog("[data-other]");
+ act(() => other.focus());
+ press(other, "Escape");
+ expect(dialog).toEqual([false]);
+ expect(events).toEqual([]);
+ expect(shown(view, POPUPS.model.popup)).toBe(true);
+ view.unmount();
+ });
+
+ test("Escape inside the add menu leaves a host-opened list open", () => {
+ const events: string[] = [];
+ const view = mount();
+ const trigger = view.query(POPUPS.add.trigger);
+ act(() => trigger.focus());
+ press(trigger, "ArrowDown");
+ const item = view.query("[role='menuitem']");
+ expect(document.activeElement).toBe(item);
+ press(item, "Escape");
+ expect(events).toEqual(["add:true", "add:false"]);
+ expect(shown(view, POPUPS.add.popup)).toBe(false);
+ expect(shown(view, POPUPS.model.popup)).toBe(true);
+ expect(document.activeElement).toBe(trigger);
+ view.unmount();
+ });
+
+ for (const first of ["popup", "list"] as const) {
+ test(`in a dialog, with the ${first} opened first, Escape closes the list, then the popup, and never the dialog`, () => {
+ const dialog: boolean[] = [];
+ const view = mount();
+ const textarea = inDialog("textarea");
+ const openPopup = () => click(inDialog("[data-host]"));
+ if (first === "popup") openPopup();
+ typeMention(textarea);
+ if (first === "list") openPopup();
+ const list = "[role='dialog'] [data-slot='prompt-input-suggestions']";
+ const popup = "[role='dialog'] [role='listbox'][aria-label='Model']";
+ expect(document.querySelector(list)).not.toBeNull();
+ expect(document.querySelector(popup)).not.toBeNull();
+
+ press(textarea, "Escape");
+ expect(dialog).toEqual([]);
+ expect(document.querySelector(list)).toBeNull();
+ expect(document.querySelector(popup)).not.toBeNull();
+
+ press(textarea, "Escape");
+ expect(dialog).toEqual([]);
+ expect(document.querySelector(popup)).toBeNull();
+ expect(document.activeElement).toBe(textarea);
+ view.unmount();
+ });
+ }
+
+ for (const which of ["add", "model"] as const) {
+ test(`the ${which} popup, open from the start in a disabled prompt, is not shown`, () => {
+ const events: string[] = [];
+ const view = mount();
+ expect(shown(view, POPUPS[which].popup)).toBe(false);
+ expect(
+ view.query(POPUPS[which].trigger).getAttribute("aria-expanded"),
+ ).toBe("false");
+ expect(events.filter((event) => !event.includes(":"))).toEqual([]);
+ view.unmount();
+ });
+
+ test(`disabling the prompt closes the open ${which} popup for good`, () => {
+ const events: string[] = [];
+ const view = mount();
+ expect(shown(view, POPUPS[which].popup)).toBe(true);
+ view.rerender();
+ expect(shown(view, POPUPS[which].popup)).toBe(false);
+ expect(events).toEqual([`${which}:false`]);
+ view.rerender();
+ expect(shown(view, POPUPS[which].popup)).toBe(false);
+ view.unmount();
+ });
+ }
+
+ test("the parts follow PromptInput's disabled", () => {
+ const view = mount();
+ expect(view.query("textarea").disabled).toBe(true);
+ const parts = view.all(
+ "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']",
+ );
+ expect(parts).toHaveLength(4);
+ for (const part of parts) {
+ expect((part as HTMLButtonElement).disabled).toBe(true);
+ }
+ view.unmount();
+ });
+
+ test("a part's own disabled wins over PromptInput's", () => {
+ const enabled = mount();
+ for (const part of enabled.all(
+ "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']",
+ )) {
+ expect((part as HTMLButtonElement).disabled).toBe(false);
+ }
+ enabled.unmount();
+
+ const disabled = mount();
+ for (const part of disabled.all(
+ "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']",
+ )) {
+ expect((part as HTMLButtonElement).disabled).toBe(true);
+ }
+ disabled.unmount();
+ });
+});
diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx
new file mode 100644
index 00000000..316cbe64
--- /dev/null
+++ b/src/ui/prompt-input-toolbar.tsx
@@ -0,0 +1,906 @@
+"use client";
+
+import { Check, ChevronDown, Plus, Reply, X } from "lucide-react";
+import {
+ forwardRef,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import type * as React from "react";
+
+import { useControllableState } from "../hooks/use-controllable-state.js";
+import { useDismissablePopover } from "../hooks/use-dismissable-popover.js";
+import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js";
+import { cn } from "../lib/utils.js";
+import { usePromptInputDisabled } from "./prompt-input.js";
+
+function assignRef(ref: React.Ref | undefined, node: T | null) {
+ if (typeof ref === "function") ref(node);
+ else if (ref !== null && ref !== undefined) ref.current = node;
+}
+
+/** Where a key moves the current item in a list of `length`: arrows step
+ * (wrapping when `wrap`, stopping at the ends otherwise), Home and End jump.
+ * `undefined` for any other key. */
+function stepIndex(
+ key: string,
+ current: number,
+ length: number,
+ wrap: boolean,
+): number | undefined {
+ if (length === 0) return undefined;
+ switch (key) {
+ case "ArrowDown":
+ return wrap ? (current + 1) % length : Math.min(current + 1, length - 1);
+ case "ArrowUp":
+ if (current < 0) return length - 1;
+ return wrap ? (current - 1 + length) % length : Math.max(current - 1, 0);
+ case "Home":
+ return 0;
+ case "End":
+ return length - 1;
+ default:
+ return undefined;
+ }
+}
+
+/** The root of a toolbar popup while it is open; each root carries
+ * `data-state="open"` only then. */
+const OPEN_POPUP = [
+ "prompt-input-add-menu",
+ "prompt-input-model-select",
+ "prompt-input-mode-select",
+]
+ .map((slot) => `[data-slot=${slot}][data-state=open]`)
+ .join(",");
+
+/**
+ * While `open`, claims Escape for the popup so that one Escape closes only
+ * the popup, not an enclosing dialog as well. A dialog dismisses on Escape
+ * from a capture listener on the document unless the event is already
+ * prevented, so this prevents it earlier, in the window's capture phase.
+ * Escape pressed inside `rootRef` is then the popup's own keys' to close.
+ * Escape pressed elsewhere in the enclosing `PromptInput` (in the textarea,
+ * say, beside a popup the host opened) closes the popup here and leaves
+ * focus where it is. Left alone are an Escape from outside the
+ * `PromptInput`, which belongs to whatever layer it was pressed in, one
+ * inside another open toolbar popup, which that popup closes, and any while
+ * the `PromptInput`'s suggestion list is showing, which the list takes.
+ */
+function useClaimEscape(
+ open: boolean,
+ rootRef: React.RefObject,
+ setOpen: (open: boolean) => void,
+) {
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ const root = rootRef.current;
+ const { target } = event;
+ if (
+ event.key !== "Escape" ||
+ event.defaultPrevented ||
+ root === null ||
+ !(target instanceof Element)
+ ) {
+ return;
+ }
+ const inside = root.contains(target);
+ if (!inside) {
+ const box = root.closest("[data-slot=prompt-input]");
+ if (
+ box === null ||
+ target.closest("[data-slot=prompt-input]") !== box
+ ) {
+ return;
+ }
+ const listShowing =
+ box.querySelector("[data-slot=prompt-input-suggestions]") !== null;
+ const inOther = target.closest(OPEN_POPUP) !== null;
+ if (listShowing || inOther) return;
+ }
+ event.preventDefault();
+ if (!inside) setOpen(false);
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, [open, rootRef, setOpen]);
+}
+
+/**
+ * What a popup shows of its `open` state: closed while `disabled`, whatever
+ * its owner holds, so a disabled part offers nothing to choose. Turning
+ * disabled while open closes it for the owner too, so it stays closed once
+ * the part is enabled again.
+ */
+function useClosedWhileDisabled(
+ open: boolean,
+ disabled: boolean,
+ setOpen: (open: boolean) => void,
+): boolean {
+ // The latest `setOpen`, which is new on every render of a popup the owner
+ // controls, so the owner is asked once per change rather than each render.
+ const setOpenRef = useRef(setOpen);
+ useLayoutEffect(() => {
+ setOpenRef.current = setOpen;
+ });
+ useEffect(() => {
+ if (disabled && open) setOpenRef.current(false);
+ }, [disabled, open]);
+ return open && !disabled;
+}
+
+/** Open state a parent may own, as on every popup here. */
+type PopupStateProps = {
+ readonly open?: boolean;
+ readonly defaultOpen?: boolean;
+ readonly onOpenChange?: (open: boolean) => void;
+};
+
+/** Which edge of its trigger a popup lines up with. */
+export type PromptInputPopupAlign = "start" | "end";
+
+// Above the textarea (z-10) and the suggestion list (z-20); the popups open
+// upward, over the box, since the box sits at the bottom of the screen.
+const POPUP =
+ "absolute bottom-full z-30 mb-2 flex max-h-72 w-max max-w-80 min-w-48 flex-col gap-px overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg";
+
+const ICON_BUTTON =
+ "grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50";
+
+export type PromptInputToolbarProps = React.ComponentProps<"div">;
+
+/**
+ * The row of controls under the text. Rendered last inside `PromptInput`, it
+ * takes a row of its own; without it the box stays a single row. Put `ms-auto`
+ * on the first control that belongs at the far end (a model select, say, or
+ * `PromptInputSubmit`).
+ *
+ * Inside a disabled `PromptInput`, the add menu, the selects and
+ * `PromptInputButton` are disabled too unless given their own `disabled`.
+ */
+export function PromptInputToolbar({
+ className,
+ ...props
+}: PromptInputToolbarProps) {
+ return (
+
+ );
+}
+
+export type PromptInputButtonProps = Omit<
+ React.ComponentProps<"button">,
+ "type" | "aria-label"
+> & {
+ /** Toolbar buttons show only an icon, so the name is required. */
+ readonly "aria-label": string;
+};
+
+/** An icon button sized for the toolbar: voice, attach, anything the host
+ * adds. Never submits the form it sits in. `disabled` defaults to the
+ * enclosing `PromptInput`'s. */
+export function PromptInputButton({
+ disabled,
+ className,
+ ...props
+}: PromptInputButtonProps) {
+ const boxDisabled = usePromptInputDisabled();
+ return (
+
+ );
+}
+
+/** One action in `PromptInputAddMenu`. */
+export type PromptInputAddMenuItem = {
+ readonly id: string;
+ readonly label: string;
+ /** Decorative; hidden from assistive technology. */
+ readonly icon?: React.ReactNode;
+ readonly detail?: string;
+ /** Listed but not choosable; the arrow keys pass over it. */
+ readonly disabled?: boolean;
+ /** Runs after the menu has closed and focus is back on its trigger, so an
+ * action that moves focus (into the textarea, a file picker) keeps it. */
+ readonly onSelect: () => void;
+};
+
+export type PromptInputAddMenuProps = Omit<
+ React.ComponentProps<"div">,
+ "children"
+> &
+ PopupStateProps & {
+ readonly items: readonly PromptInputAddMenuItem[];
+ /** Defaults to the enclosing `PromptInput`'s `disabled`. */
+ readonly disabled?: boolean;
+ /** The trigger's and the menu's accessible name. */
+ readonly label?: string;
+ readonly align?: PromptInputPopupAlign;
+ };
+
+/**
+ * The `+` button and its menu of ways to add to the message: files, a
+ * mention, a command. What each one does is the host's, through `onSelect`.
+ *
+ * A menu button: Enter, Space or ArrowDown opens it on the first item,
+ * ArrowUp on the last; the arrows move round the items, Home and End jump,
+ * Escape closes and returns focus to the trigger, Tab closes and moves on.
+ * A press outside closes it without taking focus back, as does Escape from
+ * elsewhere in the enclosing `PromptInput` (the textarea, beside a menu the
+ * host opened).
+ */
+export const PromptInputAddMenu = forwardRef<
+ HTMLDivElement,
+ PromptInputAddMenuProps
+>(function PromptInputAddMenu(
+ {
+ items,
+ disabled: disabledProp,
+ label = "Add",
+ align = "start",
+ open: openProp,
+ defaultOpen = false,
+ onOpenChange,
+ className,
+ ...props
+ },
+ ref,
+) {
+ const popover = useDismissablePopover({
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ closeOnEscape: false,
+ });
+ const { setOpen, rootRef, triggerRef, close } = popover;
+ const boxDisabled = usePromptInputDisabled();
+ const disabled = disabledProp ?? boxDisabled;
+ const open = useClosedWhileDisabled(popover.open, disabled, setOpen);
+ useClaimEscape(open, rootRef, setOpen);
+ const menuRef = useRef(null);
+ const menuId = useId();
+ // Set only when the person opens the menu from its trigger, so a menu that
+ // is open from the start (or opened by the host) does not take focus.
+ // Dropped when the owner does not open the menu at once, so the owner's
+ // own later open does not take focus either.
+ const [focusOnOpen, setFocusOnOpen] = useState<"first" | "last" | null>(null);
+ if (focusOnOpen !== null && !open) setFocusOnOpen(null);
+
+ function enabledItems(): HTMLElement[] {
+ const menu = menuRef.current;
+ if (menu === null) return [];
+ return [
+ ...menu.querySelectorAll(
+ "[role='menuitem']:not([aria-disabled='true'])",
+ ),
+ ];
+ }
+
+ function focusItem(which: "first" | "last") {
+ const enabled = enabledItems();
+ (which === "first" ? enabled[0] : enabled.at(-1))?.focus();
+ }
+
+ useEffect(() => {
+ if (open && focusOnOpen !== null) focusItem(focusOnOpen);
+ }, [open, focusOnOpen]);
+
+ function openOn(which: "first" | "last") {
+ if (open) {
+ focusItem(which);
+ return;
+ }
+ setFocusOnOpen(which);
+ setOpen(true);
+ }
+
+ if (items.length === 0) return null;
+ return (
+ {
+ rootRef.current = node;
+ assignRef(ref, node);
+ }}
+ data-slot="prompt-input-add-menu"
+ data-state={open ? "open" : undefined}
+ className={cn("relative inline-flex", className)}
+ >
+
+ {open ? (
+
+ ) : null}
+
+ );
+});
+
+/** An option as the shared select draws it. */
+type SelectOption = {
+ readonly id: string;
+ /** What the trigger shows while this option is chosen. */
+ readonly short: string;
+ readonly name: string;
+ readonly detail?: string | undefined;
+ readonly group?: string | undefined;
+};
+
+type SelectGroup = {
+ readonly name: string | undefined;
+ readonly options: readonly SelectOption[];
+};
+
+/** Options grouped in order of each group's first appearance. */
+function groupOptions(options: readonly SelectOption[]): SelectGroup[] {
+ const groups: { name: string | undefined; options: SelectOption[] }[] = [];
+ for (const option of options) {
+ const group = groups.find((entry) => entry.name === option.group);
+ if (group === undefined) {
+ groups.push({ name: option.group, options: [option] });
+ } else {
+ group.options.push(option);
+ }
+ }
+ return groups;
+}
+
+/** The value, open-state and layout props both selects share. */
+type SelectStateProps = Omit<
+ React.ComponentProps<"div">,
+ "children" | "defaultValue" | "onChange"
+> &
+ PopupStateProps & {
+ /** The chosen option's `id`. Supply it with `onValueChange` to own it. */
+ readonly value?: string;
+ /** Seeds the select's own state when `value` is not given. Without it,
+ * the select shows the first option until one is chosen, including
+ * options that arrive after mount; that default is not a choice, so it
+ * does not call `onValueChange`. */
+ readonly defaultValue?: string;
+ readonly onValueChange?: (id: string) => void;
+ /** Defaults to the enclosing `PromptInput`'s `disabled`. */
+ readonly disabled?: boolean;
+ readonly align?: PromptInputPopupAlign;
+ };
+
+type OptionSelectProps = SelectStateProps & {
+ readonly slot: string;
+ /** The trigger's and the list's accessible name; also what the trigger
+ * shows while the value matches no option. */
+ readonly label: string;
+ readonly options: readonly SelectOption[];
+};
+
+/**
+ * A select-only combobox: focus stays on the trigger, which points at the
+ * current option with `aria-activedescendant` while the list is open, so
+ * focus is already home when the list closes. Enter, Space or the arrows
+ * open it on the chosen option, Home and End on the first or last; the
+ * arrows move, Home and End jump, Enter or Space chooses, Escape and Tab
+ * close without choosing. Escape from elsewhere in the enclosing
+ * `PromptInput` (the textarea, beside a list the host opened) closes it too
+ * and leaves focus where it is.
+ */
+const OptionSelect = forwardRef(function OptionSelect(
+ {
+ slot,
+ label,
+ options,
+ value: valueProp,
+ defaultValue,
+ onValueChange,
+ open: openProp,
+ defaultOpen = false,
+ onOpenChange,
+ disabled: disabledProp,
+ align = "start",
+ className,
+ ...props
+ }: Omit,
+ ref: React.ForwardedRef,
+) {
+ // Undefined until something is chosen: the first option then stands in,
+ // read from the current list so options that load late still get it.
+ const [picked, setPicked] = useControllableState({
+ value: valueProp,
+ defaultValue,
+ onChange:
+ onValueChange === undefined
+ ? undefined
+ : (id) => {
+ if (id !== undefined) onValueChange(id);
+ },
+ name: slot,
+ });
+ const popover = useDismissablePopover({
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ closeOnEscape: false,
+ });
+ const { setOpen, rootRef, triggerRef, close } = popover;
+ const boxDisabled = usePromptInputDisabled();
+ const disabled = disabledProp ?? boxDisabled;
+ const open = useClosedWhileDisabled(popover.open, disabled, setOpen);
+ useClaimEscape(open, rootRef, setOpen);
+ const listId = useId();
+ const [activeId, setActiveId] = useState(undefined);
+ // However the list closed (a press outside, the host), the next open
+ // starts on the chosen option, so the highlight is dropped on close.
+ const [wasOpen, setWasOpen] = useState(open);
+ if (wasOpen !== open) {
+ setWasOpen(open);
+ if (!open) setActiveId(undefined);
+ }
+
+ const groups = groupOptions(options);
+ // The order the arrows walk is the order on screen, groups and all.
+ const ordered = groups.flatMap((group) => group.options);
+ const value = picked ?? ordered[0]?.id;
+ const chosen = ordered.find((option) => option.id === value);
+ const current =
+ ordered.find((option) => option.id === activeId) ?? chosen ?? ordered[0];
+ const currentRef = useScrollCurrentIntoView(
+ open ? (current?.id ?? "") : "",
+ );
+
+ const optionId = (option: SelectOption) =>
+ `${listId}-${ordered.indexOf(option)}`;
+
+ function show(option: SelectOption | undefined) {
+ setActiveId(option?.id);
+ setOpen(true);
+ }
+
+ function hide() {
+ setActiveId(undefined);
+ close();
+ }
+
+ function choose(option: SelectOption) {
+ setPicked(option.id);
+ hide();
+ }
+
+ if (ordered.length === 0) return null;
+
+ const renderOption = (option: SelectOption) => {
+ const active = option.id === current?.id;
+ const selected = option.id === chosen?.id;
+ return (
+ {
+ if (!active) setActiveId(option.id);
+ }}
+ onClick={() => choose(option)}
+ >
+
+ {option.name}
+ {option.detail === undefined ? null : (
+
+ {option.detail}
+
+ )}
+
+
+
+ );
+ };
+
+ return (
+ {
+ rootRef.current = node;
+ assignRef(ref, node);
+ }}
+ data-slot={slot}
+ data-state={open ? "open" : undefined}
+ className={cn("relative inline-flex min-w-0", className)}
+ >
+
+ {open ? (
+
event.preventDefault()}
+ >
+ {groups.map((group, groupIndex) =>
+ group.name === undefined ? (
+ group.options.map(renderOption)
+ ) : (
+
+
+ {group.name}
+
+ {group.options.map(renderOption)}
+
+ ),
+ )}
+
+ ) : null}
+
+ );
+});
+
+/** A model to choose from in `PromptInputModelSelect`. */
+export type PromptInputModelOption = {
+ readonly id: string;
+ readonly name: string;
+ /** What the trigger shows while it is chosen; defaults to `name`. */
+ readonly short?: string;
+ readonly detail?: string;
+ /** Models with the same group (a provider, say) are listed together
+ * under it. */
+ readonly group?: string;
+};
+
+export type PromptInputModelSelectProps = SelectStateProps & {
+ readonly models: readonly PromptInputModelOption[];
+ /** The accessible name; also shown when `value` matches no model. */
+ readonly label?: string;
+};
+
+/**
+ * Which model answers. The trigger shows the chosen model's short name; the
+ * list groups models by `group`. A model only: anything finer, such as a
+ * reasoning level, is a separate control, so `onValueChange` stays a single
+ * model id.
+ */
+export const PromptInputModelSelect = forwardRef<
+ HTMLDivElement,
+ PromptInputModelSelectProps
+>(function PromptInputModelSelect(
+ { models, label = "Model", align = "end", ...props },
+ ref,
+) {
+ return (
+ ({
+ id: model.id,
+ short: model.short ?? model.name,
+ name: model.name,
+ detail: model.detail,
+ group: model.group,
+ }))}
+ />
+ );
+});
+
+/** A way of working, offered by `PromptInputModeSelect`. */
+export type PromptInputModeOption = {
+ readonly id: string;
+ readonly label: string;
+ readonly detail?: string;
+};
+
+export type PromptInputModeSelectProps = SelectStateProps & {
+ readonly modes: readonly PromptInputModeOption[];
+ /** The accessible name; also shown when `value` matches no mode. */
+ readonly label?: string;
+};
+
+/** How the agent works this turn. The modes' labels and details are the
+ * host's own strings; the trigger shows the chosen mode's label. */
+export const PromptInputModeSelect = forwardRef<
+ HTMLDivElement,
+ PromptInputModeSelectProps
+>(function PromptInputModeSelect({ modes, label = "Mode", ...props }, ref) {
+ return (
+ ({
+ id: mode.id,
+ short: mode.label,
+ name: mode.label,
+ detail: mode.detail,
+ }))}
+ />
+ );
+});
+
+export type PromptInputFloatProps = React.ComponentProps<"div">;
+
+/**
+ * What floats above the box: the reply context, attachments, a notice.
+ * Rendered inside `PromptInput`, it sits over the content above the box
+ * rather than pushing it. It steps aside while the suggestion list or a
+ * toolbar popup is open, since those open over the same spot.
+ */
+export function PromptInputFloat({
+ className,
+ ...props
+}: PromptInputFloatProps) {
+ return (
+
+ );
+}
+
+export type PromptInputContextProps = Omit<
+ React.ComponentProps<"div">,
+ "children"
+> & {
+ /** What the next message answers, e.g. "Replying to". */
+ readonly label: string;
+ readonly detail?: string;
+ /** Replaces the reply arrow. Decorative. */
+ readonly icon?: React.ReactNode;
+ /** Offered as a clear button when given. */
+ readonly onClear?: () => void;
+ /** The clear button's accessible name. */
+ readonly clearLabel?: string;
+};
+
+/** A chip saying what the next message is about: the message it replies
+ * to, the item it refers to. Usually inside `PromptInputFloat`. */
+export function PromptInputContext({
+ label,
+ detail,
+ icon,
+ onClear,
+ clearLabel = "Clear",
+ className,
+ ...props
+}: PromptInputContextProps) {
+ return (
+
+
+ {icon ?? }
+
+
+ {label}
+ {detail === undefined ? null : <> {detail}>}
+
+ {onClear === undefined ? null : (
+
+ )}
+
+ );
+}
+
+export type PromptInputNoteProps = React.ComponentProps<"p">;
+
+/** Small print under the box, such as a reminder that answers can be
+ * wrong. Rendered after `PromptInput`, not inside it. */
+export function PromptInputNote({ className, ...props }: PromptInputNoteProps) {
+ return (
+
+ );
+}
diff --git a/src/ui/prompt-input.tsx b/src/ui/prompt-input.tsx
index cd2cd1e6..7cb3794f 100644
--- a/src/ui/prompt-input.tsx
+++ b/src/ui/prompt-input.tsx
@@ -54,6 +54,13 @@ function usePromptInputContext(part: string): PromptInputContextValue {
return context;
}
+/** Whether the enclosing `PromptInput` is `disabled`; `false` outside one.
+ * The toolbar parts default their own `disabled` to it, and a part the host
+ * builds can do the same. */
+export function usePromptInputDisabled(): boolean {
+ return useContext(PromptInputContext)?.disabled ?? false;
+}
+
function optionId(listId: string, token: PromptToken): string {
return `${listId}-${token.id}`;
}
@@ -79,6 +86,8 @@ export type PromptInputProps = Omit<
readonly tokens?: readonly PromptToken[];
/** While not `ready`, neither Enter nor the send button submits. */
readonly status?: PromptInputStatus;
+ /** Disables the textarea and sending, and the toolbar parts inside that do
+ * not set their own `disabled`. */
readonly disabled?: boolean;
};
@@ -147,6 +156,8 @@ export function PromptInput({
data-disabled={disabled ? "" : undefined}
className={cn(
"relative flex items-end gap-2 rounded-lg border border-input bg-background p-1.5",
+ // A `PromptInputToolbar` placed last takes a row of its own.
+ "has-[>[data-slot=prompt-input-toolbar]]:flex-wrap",
// The textarea gives up its own outline for one round the box.
"has-[textarea:focus-visible]:outline-2 has-[textarea:focus-visible]:outline-offset-2 has-[textarea:focus-visible]:outline-ring",
className,
diff --git a/stories/chat/prompt-input.stories.tsx b/stories/chat/prompt-input.stories.tsx
index 13dd10a3..e6246578 100644
--- a/stories/chat/prompt-input.stories.tsx
+++ b/stories/chat/prompt-input.stories.tsx
@@ -1,3 +1,4 @@
+import { AtSign, AudioLines, Paperclip, SquareSlash } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
@@ -9,6 +10,18 @@ import {
PromptInputTextarea,
type PromptInputStatus,
} from "../../src/ui/prompt-input.js";
+import {
+ PromptInputAddMenu,
+ PromptInputButton,
+ PromptInputContext,
+ PromptInputFloat,
+ PromptInputModeSelect,
+ PromptInputModelSelect,
+ PromptInputNote,
+ PromptInputToolbar,
+ type PromptInputModelOption,
+ type PromptInputModeOption,
+} from "../../src/ui/prompt-input-toolbar.js";
export default { title: "Chat / Prompt input" };
@@ -115,3 +128,194 @@ export const Replying = () => (
export const LongDraft = () => ;
export const Disabled = () => ;
+
+const MODELS: readonly PromptInputModelOption[] = [
+ {
+ id: "swift",
+ name: "Swift 2",
+ short: "Swift",
+ detail: "Quick answers",
+ group: "Fast",
+ },
+ {
+ id: "lite",
+ name: "Lite 1",
+ short: "Lite",
+ detail: "Lowest cost",
+ group: "Fast",
+ },
+ {
+ id: "deep",
+ name: "Deep 4",
+ short: "Deep",
+ detail: "Careful, slower reasoning",
+ group: "Thorough",
+ },
+];
+
+const MODES: readonly PromptInputModeOption[] = [
+ { id: "ask", label: "Ask", detail: "Answers without taking action" },
+ { id: "plan", label: "Plan", detail: "Drafts steps for you to approve" },
+ { id: "agent", label: "Agent", detail: "Acts on your behalf" },
+];
+
+type ToolbarPromptProps = {
+ readonly addMenu?: boolean;
+ readonly mode?: boolean;
+ readonly model?: boolean;
+ readonly voice?: boolean;
+ readonly replying?: boolean;
+ readonly note?: boolean;
+ readonly openPicker?: "model" | "mode" | "add";
+ readonly disabled?: boolean;
+};
+
+/** A prompt with whichever toolbar parts are switched on. */
+function ToolbarPrompt({
+ addMenu = false,
+ mode = false,
+ model = false,
+ voice = false,
+ replying = false,
+ note = false,
+ openPicker,
+ disabled = false,
+}: ToolbarPromptProps) {
+ const [value, setValue] = useState("");
+ const [modelId, setModelId] = useState("deep");
+ const [modeId, setModeId] = useState("agent");
+ const [context, setContext] = useState(replying);
+ const textareaRef = useRef(null);
+ const startToken = (prefix: string) => {
+ setValue(
+ (draft) =>
+ (draft === "" || draft.endsWith(" ") ? draft : `${draft} `) + prefix,
+ );
+ textareaRef.current?.focus();
+ };
+ const toolbar = addMenu || mode || model || voice;
+ return (
+
+ setValue("")}
+ tokens={TOKENS}
+ disabled={disabled}
+ >
+ {context ? (
+
+ setContext(false)}
+ />
+
+ ) : null}
+
+
+ {toolbar ? (
+
+ {addMenu ? (
+ ,
+ onSelect: () => undefined,
+ },
+ {
+ id: "mention",
+ label: "Mention an agent",
+ icon: ,
+ onSelect: () => startToken("@"),
+ },
+ {
+ id: "command",
+ label: "Use a command",
+ icon: ,
+ onSelect: () => startToken("/"),
+ },
+ ]}
+ />
+ ) : null}
+ {mode ? (
+
+ ) : null}
+
+ {model ? (
+
+ ) : null}
+ {voice ? (
+
+
+
+ ) : null}
+
undefined} />
+
+
+ ) : (
+ undefined} />
+ )}
+
+ {note ? (
+
+ Agents can make mistakes. Check important work.
+
+ ) : null}
+
+ );
+}
+
+/** Every optional part on: add menu, mode and model pickers, voice, the
+ * reply chip floating above and the note below. */
+export const EverythingOn = () => (
+
+);
+
+/** The model picker open, grouped, with the chosen model checked. */
+export const ModelPickerOpen = () => (
+
+);
+
+/** The mode picker open on the current mode. */
+export const ModePickerOpen = () => (
+
+);
+
+/** The `+` menu open: files, a mention, a command. */
+export const AddMenuOpen = () => (
+
+);
+
+/** Small print under the box; nothing else added. */
+export const WithNote = () => ;
+
+/** The chip says what the next message answers; its button clears it. */
+export const ReplyingChip = () => ;
+
+/** Disabled: the toolbar parts follow the prompt's `disabled`; none is
+ * passed its own. */
+export const ToolbarDisabled = () => (
+
+);