panelui-web 0.4.0 → 0.6.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.
@@ -0,0 +1,779 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ /**
4
+ * Prompt — where a message to an assistant is written, with what goes with
5
+ * it: files, a model, tools, and a button that sends it.
6
+ *
7
+ * ```tsx
8
+ * <Prompt onSubmit={({ text, files }) => send(text, files)} status={status} onStop={stop}>
9
+ * <Prompt.Field>
10
+ * <Prompt.Attachments />
11
+ * <Prompt.Textarea placeholder="Ask anything" />
12
+ * <Prompt.Footer>
13
+ * <Prompt.Tools>
14
+ * <Prompt.Menu>
15
+ * <Prompt.AttachItem />
16
+ * </Prompt.Menu>
17
+ * <Prompt.Model items={models} defaultValue="swift" />
18
+ * </Prompt.Tools>
19
+ * <Prompt.Submit />
20
+ * </Prompt.Footer>
21
+ * </Prompt.Field>
22
+ * <Prompt.Status>Reading the files</Prompt.Status>
23
+ * </Prompt>
24
+ * ```
25
+ *
26
+ * ## A field in a tray
27
+ *
28
+ * The field is a raised card. What is said about it, rather than in it, sits
29
+ * in bands that the card overlaps: `Prompt.Banner` above for something to do
30
+ * first, `Prompt.Status` below for what the answer is doing, and
31
+ * `Prompt.Context` below, inset, for where the message is going. A band opens
32
+ * and closes with its `open` prop, folding under the card, so a band can come
33
+ * and go without the page jumping by more than its own height.
34
+ *
35
+ * ## Sending
36
+ *
37
+ * Enter sends and Shift+Enter starts a new line; Enter while a character is
38
+ * still being composed is left to the input method. `onSubmit` is handed the
39
+ * text, trimmed, and the files, and the field is cleared. Nothing is sent
40
+ * while it is empty or while `status` is `submitted` or `streaming`.
41
+ *
42
+ * ## One button, four jobs
43
+ *
44
+ * `Prompt.Submit` sends. While the answer is on its way it stops it instead,
45
+ * calling `onStop`, with a ring turning round it. Given `onVoice`, it is a
46
+ * voice button while the field is empty, and its bars move while `listening`.
47
+ * The icons cross-fade in place, so the button never moves under the pointer.
48
+ *
49
+ * ## Files
50
+ *
51
+ * Files arrive from `Prompt.AttachItem` in the menu or `Prompt.Attach`, from
52
+ * ⌘U or Ctrl+U in the field, from a paste, or dropped on the field.
53
+ * `accept` and `maxFiles` decide which are taken, and `onReject` is told about
54
+ * the rest. Images are shown as thumbnails, anything else as a chip with its
55
+ * name and size.
56
+ */
57
+ import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
58
+ import { tv } from 'tailwind-variants';
59
+ import { Button } from '../button/index.js';
60
+ import { Menu } from '../menu/index.js';
61
+ import { Select } from '../select/index.js';
62
+ import { Textarea } from '../textarea/index.js';
63
+ const promptVariants = tv({
64
+ slots: {
65
+ root: 'isolate flex w-full flex-col',
66
+ /*
67
+ * A band is a grid row that goes from no height to its own, so its
68
+ * opening can be animated without measuring it. The negative margin that
69
+ * tucks it under the card moves with it, or the card would be pulled
70
+ * over nothing while the band is shut.
71
+ */
72
+ band: 'grid transition-[grid-template-rows,margin] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
73
+ bandClip: 'min-h-0 overflow-hidden',
74
+ bandInner: 'flex items-center gap-2.5 bg-muted px-4 text-sm text-muted-foreground transition-opacity duration-300 motion-reduce:transition-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
75
+ bannerAction: 'ms-auto h-7 px-3 text-xs',
76
+ // The card. It sits over the bands, so its rounded corners show the tray behind them.
77
+ field: [
78
+ 'relative z-10 flex flex-col rounded-[1.375rem] border bg-card text-card-foreground shadow-sm',
79
+ 'transition-[border-color,box-shadow] duration-200',
80
+ 'focus-within:border-ring/70 has-[textarea:focus-visible]:shadow-md',
81
+ 'data-dragging:border-primary data-dragging:ring-4 data-dragging:ring-primary/10',
82
+ 'data-[status=error]:border-destructive/60',
83
+ // One line: the parts sit in a row, and the text takes the room between them.
84
+ 'data-[layout=inline]:flex-row data-[layout=inline]:flex-wrap data-[layout=inline]:items-end data-[layout=inline]:gap-1.5 data-[layout=inline]:rounded-[1.75rem] data-[layout=inline]:p-2',
85
+ ],
86
+ textarea: 'px-4 pb-1 pt-3.5 text-[0.9375rem] leading-6 in-data-[layout=inline]:min-w-0 in-data-[layout=inline]:flex-1 in-data-[layout=inline]:basis-40 in-data-[layout=inline]:px-1.5 in-data-[layout=inline]:py-2',
87
+ attachments: 'flex flex-wrap gap-2 px-3 pt-3 in-data-[layout=inline]:basis-full in-data-[layout=inline]:px-1 in-data-[layout=inline]:pb-1 in-data-[layout=inline]:pt-1',
88
+ attachment: [
89
+ 'group/attachment relative flex items-center gap-2 rounded-xl border bg-background text-xs',
90
+ 'transition-[opacity,scale] duration-200 ease-out starting:scale-90 starting:opacity-0 motion-reduce:transition-none',
91
+ ],
92
+ // Not clipped, so the remove button can sit over the corner; the picture rounds itself.
93
+ attachmentImage: 'size-14 p-0',
94
+ attachmentFile: 'h-14 max-w-56 py-2 pe-8 ps-2',
95
+ attachmentGlyph: 'flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4',
96
+ attachmentName: 'block truncate font-medium text-foreground',
97
+ attachmentMeta: 'block truncate text-muted-foreground',
98
+ attachmentRemove: [
99
+ 'absolute -end-1.5 -top-1.5 flex size-5 cursor-pointer items-center justify-center rounded-full border bg-background text-muted-foreground shadow-xs outline-none',
100
+ 'opacity-0 transition-opacity group-hover/attachment:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100',
101
+ 'hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-3',
102
+ ],
103
+ // Bottom-aligned: when the tools wrap, the submit button stays on their last row.
104
+ footer: 'flex items-end justify-between gap-2 p-2.5 pt-1.5',
105
+ // The padding makes a row of tools as tall as the submit button, so a single row lines up with it.
106
+ tools: 'flex min-w-0 flex-wrap items-center gap-1.5 py-0.5',
107
+ // Switched on, a tool is drawn in the theme's info colour, so it reads as on at a glance.
108
+ tool: [
109
+ 'aria-pressed:border-[color-mix(in_oklab,var(--info)_40%,transparent)] aria-pressed:bg-[color-mix(in_oklab,var(--info)_12%,transparent)] aria-pressed:text-[var(--info)]',
110
+ 'aria-pressed:hover:not-disabled:bg-[color-mix(in_oklab,var(--info)_18%,transparent)] aria-pressed:hover:not-disabled:text-[var(--info)]',
111
+ ],
112
+ // The plus turns to a cross while its menu is open.
113
+ menuTrigger: 'data-popup-open:bg-accent [&_svg]:transition-transform [&_svg]:duration-200 data-popup-open:[&_svg]:rotate-45 motion-reduce:[&_svg]:transition-none',
114
+ modelValue: 'flex min-w-0 items-center gap-2 [&_svg]:size-4',
115
+ submit: [
116
+ 'relative size-10 shrink-0 overflow-visible rounded-full p-0',
117
+ 'disabled:opacity-40',
118
+ ],
119
+ submitIcon: 'absolute inset-0 m-auto flex items-center justify-center transition-[opacity,scale,rotate] duration-200 ease-out motion-reduce:transition-none [&_svg]:size-[18px]',
120
+ submitRing: 'pointer-events-none absolute -inset-[3px] size-[calc(100%+6px)] text-primary transition-opacity duration-200',
121
+ statusText: 'min-w-0 truncate bg-[linear-gradient(90deg,var(--muted-foreground)_35%,var(--foreground)_50%,var(--muted-foreground)_65%)] bg-[length:250%_100%] bg-clip-text text-transparent',
122
+ statusSpinner: 'ms-auto size-4 animate-spin text-muted-foreground motion-reduce:animate-none',
123
+ context: [
124
+ 'relative mx-4 -mt-5 flex items-center gap-x-4 gap-y-1 rounded-b-[1rem] border bg-muted px-3.5 pb-2 pt-6.5 text-xs text-muted-foreground',
125
+ 'flex-wrap [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3.5',
126
+ ],
127
+ contextItem: 'inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap',
128
+ contextButton: 'cursor-pointer rounded-md outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
129
+ tasksHeader: 'flex h-9 w-full cursor-pointer items-center gap-2.5 px-4 text-start outline-none transition-colors hover:text-foreground focus-visible:text-foreground',
130
+ tasksTitle: 'min-w-0 truncate font-medium text-foreground',
131
+ tasksCount: 'shrink-0 tabular-nums',
132
+ tasksChevron: 'ms-auto transition-transform duration-200 motion-reduce:transition-none',
133
+ tasksBody: 'grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none',
134
+ tasksList: [
135
+ 'relative flex flex-col overflow-y-auto overscroll-contain px-2 py-1 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]',
136
+ // Rows fade out at the edges they scroll past, instead of being cut by them.
137
+ '[mask-image:linear-gradient(to_bottom,transparent,black_0.75rem,black_calc(100%-0.75rem),transparent)]',
138
+ ],
139
+ task: 'flex items-start gap-2.5 rounded-lg px-2 py-1 text-sm',
140
+ taskMark: 'mt-0.5 flex size-4 shrink-0 items-center justify-center',
141
+ // Pops in as the task finishes.
142
+ taskDone: 'size-4 transition-[scale,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] starting:scale-50 starting:opacity-0 motion-reduce:transition-none',
143
+ taskPending: 'size-3.5 rounded-full border-[1.5px] border-dashed border-muted-foreground/50',
144
+ taskError: 'flex size-4 items-center justify-center rounded-full bg-destructive text-[0.625rem] font-bold leading-none text-white',
145
+ commandsLabel: 'px-4 pb-1 pt-2.5 text-xs font-medium',
146
+ commandsList: 'flex flex-col overflow-y-auto overscroll-contain px-2 pb-1 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]',
147
+ command: 'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm text-foreground aria-selected:bg-card aria-selected:shadow-xs [&_svg]:text-muted-foreground',
148
+ commandName: 'shrink-0 font-medium',
149
+ commandDescription: 'min-w-0 truncate text-muted-foreground',
150
+ recorder: 'flex flex-col',
151
+ recorderTranscript: 'min-h-[3.25rem] px-4 pb-1 pt-3.5 text-[0.9375rem] leading-6 text-foreground',
152
+ recorderListening: 'bg-[linear-gradient(90deg,var(--muted-foreground)_35%,var(--foreground)_50%,var(--muted-foreground)_65%)] bg-[length:250%_100%] bg-clip-text text-transparent',
153
+ recorderControls: 'flex items-center gap-1.5 p-2.5 pt-1.5',
154
+ // Older sound fades out to the left as it scrolls away.
155
+ recorderWave: 'flex h-10 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_18%)]',
156
+ recorderBar: 'w-[3px] shrink-0 rounded-full bg-foreground transition-[height] duration-75 ease-out motion-reduce:transition-none',
157
+ recorderTime: 'flex shrink-0 items-center gap-1.5 ps-1.5 text-xs font-medium tabular-nums text-muted-foreground',
158
+ recorderDot: 'size-2 animate-pulse rounded-full bg-destructive motion-reduce:animate-none',
159
+ recorderDone: 'size-10 p-0 [&_svg]:size-[18px]',
160
+ hint: 'px-4 pt-2 text-xs text-muted-foreground [&_kbd]:rounded-[0.3rem] [&_kbd]:border [&_kbd]:bg-muted [&_kbd]:px-1 [&_kbd]:py-px [&_kbd]:font-sans [&_kbd]:text-[0.6875rem] [&_kbd]:text-foreground',
161
+ },
162
+ variants: {
163
+ /** Where a band sits against the card. */
164
+ edge: {
165
+ top: {
166
+ bandInner: 'rounded-t-[1.375rem] pb-[calc(0.625rem+1.5rem)] pt-2.5',
167
+ },
168
+ bottom: {
169
+ bandInner: 'rounded-b-[1.375rem] pb-2.5 pt-[calc(0.625rem+1.5rem)]',
170
+ },
171
+ },
172
+ open: { true: { bandInner: 'opacity-100' }, false: { bandInner: 'opacity-0' } },
173
+ },
174
+ compoundVariants: [
175
+ { edge: 'top', open: true, className: { band: 'mb-[-1.5rem] grid-rows-[1fr]' } },
176
+ { edge: 'top', open: false, className: { band: 'mb-0 grid-rows-[0fr]' } },
177
+ { edge: 'bottom', open: true, className: { band: 'mt-[-1.5rem] grid-rows-[1fr]' } },
178
+ { edge: 'bottom', open: false, className: { band: 'mt-0 grid-rows-[0fr]' } },
179
+ ],
180
+ });
181
+ const PromptStore = createContext(null);
182
+ function usePrompt(part) {
183
+ const prompt = useContext(PromptStore);
184
+ if (!prompt)
185
+ throw new Error(`${part} must be used inside a <Prompt>.`);
186
+ return prompt;
187
+ }
188
+ /** Whether a file is one `accept` allows: a MIME type, a `type/*` family or an extension. */
189
+ function accepts(file, accept) {
190
+ if (!accept)
191
+ return true;
192
+ const name = file.name.toLowerCase();
193
+ return accept
194
+ .split(',')
195
+ .map((rule) => rule.trim().toLowerCase())
196
+ .filter(Boolean)
197
+ .some((rule) => {
198
+ if (rule.startsWith('.'))
199
+ return name.endsWith(rule);
200
+ if (rule.endsWith('/*'))
201
+ return file.type.startsWith(rule.slice(0, -1));
202
+ return file.type === rule;
203
+ });
204
+ }
205
+ function attach(file) {
206
+ return {
207
+ id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 8)}`,
208
+ file,
209
+ preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
210
+ };
211
+ }
212
+ function PromptRoot({ value: controlledValue, defaultValue = '', onValueChange, onSubmit, status = 'idle', onStop, disabled = false, defaultFiles, accept, maxFiles, onReject, className, children, ...props }) {
213
+ const [internalValue, setInternalValue] = useState(defaultValue);
214
+ const value = controlledValue ?? internalValue;
215
+ const [attachments, setAttachments] = useState(() => (defaultFiles ?? []).map(attach));
216
+ const [dragging, setDragging] = useState(false);
217
+ const fileInput = useRef(null);
218
+ const fieldRef = useRef(null);
219
+ const keyHandler = useRef(null);
220
+ const [listbox, setListbox] = useState(null);
221
+ const fieldId = useId();
222
+ const busy = status === 'submitted' || status === 'streaming';
223
+ const empty = value.trim() === '' && attachments.length === 0;
224
+ const setValue = useCallback((next) => {
225
+ if (controlledValue === undefined)
226
+ setInternalValue(next);
227
+ onValueChange?.(next);
228
+ }, [controlledValue, onValueChange]);
229
+ // Kept in a ref as well, for the cleanup on unmount, which sees only the
230
+ // first render's state otherwise.
231
+ const live = useRef(attachments);
232
+ live.current = attachments;
233
+ useEffect(() => () => {
234
+ for (const attachment of live.current)
235
+ if (attachment.preview)
236
+ URL.revokeObjectURL(attachment.preview);
237
+ }, []);
238
+ const addFiles = useCallback((incoming) => {
239
+ const list = [...incoming];
240
+ const refused = list.filter((file) => !accepts(file, accept));
241
+ const taken = list.filter((file) => accepts(file, accept));
242
+ if (refused.length > 0)
243
+ onReject?.(refused, 'accept');
244
+ const room = maxFiles === undefined ? taken.length : Math.max(0, maxFiles - live.current.length);
245
+ if (taken.length > room)
246
+ onReject?.(taken.slice(room), 'max-files');
247
+ const added = taken.slice(0, room).map(attach);
248
+ if (added.length > 0)
249
+ setAttachments((current) => [...current, ...added]);
250
+ }, [accept, maxFiles, onReject]);
251
+ const removeAttachment = useCallback((id) => {
252
+ setAttachments((current) => {
253
+ const gone = current.find((attachment) => attachment.id === id);
254
+ if (gone?.preview)
255
+ URL.revokeObjectURL(gone.preview);
256
+ return current.filter((attachment) => attachment.id !== id);
257
+ });
258
+ }, []);
259
+ const openFileDialog = useCallback(() => fileInput.current?.click(), []);
260
+ const submit = useCallback(() => {
261
+ if (busy || disabled || empty)
262
+ return;
263
+ onSubmit?.({ text: value.trim(), files: attachments.map((attachment) => attachment.file) });
264
+ setValue('');
265
+ // The previews are not revoked here: the caller may still be showing the files.
266
+ setAttachments([]);
267
+ }, [busy, disabled, empty, onSubmit, value, attachments, setValue]);
268
+ const stop = useCallback(() => onStop?.(), [onStop]);
269
+ const context = useMemo(() => ({
270
+ value,
271
+ setValue,
272
+ attachments,
273
+ addFiles,
274
+ removeAttachment,
275
+ openFileDialog,
276
+ status,
277
+ busy,
278
+ disabled,
279
+ empty,
280
+ submit,
281
+ stop,
282
+ hasStop: onStop !== undefined,
283
+ dragging,
284
+ setDragging,
285
+ fieldId,
286
+ fieldRef,
287
+ keyHandler,
288
+ listbox,
289
+ setListbox,
290
+ }), [value, setValue, attachments, addFiles, removeAttachment, openFileDialog, status, busy, disabled, empty, submit, stop, onStop, dragging, fieldId, listbox]);
291
+ const send = (event) => {
292
+ event.preventDefault();
293
+ submit();
294
+ };
295
+ return (_jsx(PromptStore.Provider, { value: context, children: _jsxs("form", { "data-slot": "prompt", "data-status": status, "aria-busy": busy || undefined, className: promptVariants().root({ className }), onSubmit: send, ...props, children: [children, _jsx("input", { ref: fileInput, type: "file", multiple: maxFiles !== 1, accept: accept, tabIndex: -1, "aria-hidden": "true", className: "hidden", onChange: (event) => {
296
+ if (event.target.files)
297
+ addFiles(event.target.files);
298
+ // Cleared, so choosing the same file again still counts as a change.
299
+ event.target.value = '';
300
+ } })] }) }));
301
+ }
302
+ PromptRoot.displayName = 'Prompt';
303
+ /* ------------------------------------------------------------------ bands */
304
+ function Band({ edge, open, slot, className, children, ...props }) {
305
+ const slots = promptVariants({ edge, open });
306
+ const clip = useRef(null);
307
+ // Nothing in a shut band can be reached. Set on the element: how React
308
+ // writes a boolean `inert` differs between versions.
309
+ useEffect(() => {
310
+ clip.current?.toggleAttribute('inert', !open);
311
+ }, [open]);
312
+ return (_jsx("div", { "data-slot": slot, "data-open": open ? '' : undefined, className: slots.band(), children: _jsx("div", { ref: clip, className: slots.bandClip(), children: _jsx("div", { className: slots.bandInner({ className }), ...props, children: children }) }) }));
313
+ }
314
+ /** A band above the field, for something to do before or while writing: connect an account, pick a project. */
315
+ function PromptBanner({ open = true, ...props }) {
316
+ return _jsx(Band, { edge: "top", open: open, slot: "prompt-banner", ...props });
317
+ }
318
+ PromptBanner.displayName = 'Prompt.Banner';
319
+ /** The banner's button, at its end. */
320
+ function PromptBannerAction({ className, size = 'sm', shape = 'pill', ...props }) {
321
+ return (_jsx(Button, { "data-slot": "prompt-banner-action", size: size, shape: shape, className: promptVariants().bannerAction({ className }), ...props }));
322
+ }
323
+ PromptBannerAction.displayName = 'Prompt.BannerAction';
324
+ /** Runs a background sweep across the text, unless motion is reduced. */
325
+ function useShimmer(active) {
326
+ const ref = useRef(null);
327
+ useEffect(() => {
328
+ const node = ref.current;
329
+ if (!node || !active || typeof node.animate !== 'function')
330
+ return;
331
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
332
+ return;
333
+ const animation = node.animate([{ backgroundPosition: '125% 0' }, { backgroundPosition: '-25% 0' }], {
334
+ duration: 1800,
335
+ iterations: Infinity,
336
+ easing: 'linear',
337
+ });
338
+ return () => animation.cancel();
339
+ }, [active]);
340
+ return ref;
341
+ }
342
+ /** A band under the field saying what the answer is doing, with the words lit by a moving sweep. */
343
+ function PromptStatus({ open, icon, spinner = true, children, ...props }) {
344
+ const prompt = usePrompt('Prompt.Status');
345
+ const shown = open ?? prompt.busy;
346
+ const slots = promptVariants();
347
+ const text = useShimmer(shown);
348
+ return (_jsxs(Band, { edge: "bottom", open: shown, slot: "prompt-status", role: "status", ...props, children: [icon, _jsx("span", { ref: text, className: slots.statusText(), children: children }), spinner ? (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", className: slots.statusSpinner(), children: _jsx("path", { d: "M21 12a9 9 0 1 1-6.22-8.56" }) })) : null] }));
349
+ }
350
+ PromptStatus.displayName = 'Prompt.Status';
351
+ /** A strip under the field, inset from its edges, for where the message is going: a branch, a folder, a ticket. */
352
+ function PromptContext({ className, ...props }) {
353
+ return _jsx("div", { "data-slot": "prompt-context", className: promptVariants().context({ className }), ...props });
354
+ }
355
+ PromptContext.displayName = 'Prompt.Context';
356
+ /** One fact in the context strip, with an icon before it. */
357
+ function PromptContextItem({ onSelect, className, children, ...props }) {
358
+ const slots = promptVariants();
359
+ if (onSelect) {
360
+ return (_jsx("button", { type: "button", "data-slot": "prompt-context-item", onClick: onSelect, className: slots.contextItem({ className: [slots.contextButton(), className] }), children: children }));
361
+ }
362
+ return (_jsx("span", { "data-slot": "prompt-context-item", className: slots.contextItem({ className }), ...props, children: children }));
363
+ }
364
+ PromptContextItem.displayName = 'Prompt.ContextItem';
365
+ /** A quiet line under the prompt. Keys written in `<kbd>` are drawn as keys. */
366
+ function PromptHint({ className, children, ...props }) {
367
+ return (_jsx("p", { "data-slot": "prompt-hint", className: promptVariants().hint({ className }), ...props, children: children ?? (_jsxs(_Fragment, { children: [_jsx("kbd", { children: "Shift" }), " + ", _jsx("kbd", { children: "Enter" }), " for a new line"] })) }));
368
+ }
369
+ PromptHint.displayName = 'Prompt.Hint';
370
+ /** The card that holds the text, the files and the footer. Files can be dropped on it. */
371
+ function PromptField({ layout = 'stacked', className, onDragOver, onDragLeave, onDrop, ...props }) {
372
+ const prompt = usePrompt('Prompt.Field');
373
+ const hasFiles = (event) => [...event.dataTransfer.types].includes('Files');
374
+ return (_jsx("div", { ref: prompt.fieldRef, "data-slot": "prompt-field", "data-layout": layout, "data-status": prompt.status, "data-dragging": prompt.dragging ? '' : undefined, className: promptVariants().field({ className }), onDragOver: (event) => {
375
+ onDragOver?.(event);
376
+ if (prompt.disabled || !hasFiles(event))
377
+ return;
378
+ event.preventDefault();
379
+ event.dataTransfer.dropEffect = 'copy';
380
+ if (!prompt.dragging)
381
+ prompt.setDragging(true);
382
+ }, onDragLeave: (event) => {
383
+ onDragLeave?.(event);
384
+ // Leaving for a child of the field is not leaving the field.
385
+ if (!event.currentTarget.contains(event.relatedTarget))
386
+ prompt.setDragging(false);
387
+ }, onDrop: (event) => {
388
+ onDrop?.(event);
389
+ prompt.setDragging(false);
390
+ if (prompt.disabled || !hasFiles(event))
391
+ return;
392
+ event.preventDefault();
393
+ prompt.addFiles(event.dataTransfer.files);
394
+ }, ...props }));
395
+ }
396
+ PromptField.displayName = 'Prompt.Field';
397
+ /** Where the message is written. Enter sends, Shift+Enter starts a new line, and pasted files are attached. */
398
+ function PromptTextarea({ className, minRows = 2, maxRows = 10, onKeyDown, onPaste, 'aria-label': ariaLabel = 'Message', ...props }) {
399
+ const prompt = usePrompt('Prompt.Textarea');
400
+ return (_jsx(Textarea, { id: prompt.fieldId, variant: "ghost", "aria-label": ariaLabel, minRows: minRows, maxRows: maxRows, disabled: prompt.disabled, value: prompt.value, onChange: (event) => prompt.setValue(event.target.value), "aria-controls": prompt.listbox?.id, "aria-activedescendant": prompt.listbox?.active, onKeyDown: (event) => {
401
+ onKeyDown?.(event);
402
+ if (event.defaultPrevented)
403
+ return;
404
+ if (prompt.keyHandler.current?.(event))
405
+ return;
406
+ // The usual shortcut for attaching, where underlining means nothing.
407
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'u') {
408
+ event.preventDefault();
409
+ prompt.openFileDialog();
410
+ return;
411
+ }
412
+ // `isComposing`: Enter that ends a composition in an input method is not a send.
413
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
414
+ event.preventDefault();
415
+ prompt.submit();
416
+ }
417
+ }, onPaste: (event) => {
418
+ onPaste?.(event);
419
+ const files = event.clipboardData.files;
420
+ if (event.defaultPrevented || files.length === 0)
421
+ return;
422
+ event.preventDefault();
423
+ prompt.addFiles(files);
424
+ }, className: promptVariants().textarea({ className }), ...props }));
425
+ }
426
+ PromptTextarea.displayName = 'Prompt.Textarea';
427
+ function formatSize(bytes) {
428
+ if (bytes < 1024)
429
+ return `${bytes} B`;
430
+ if (bytes < 1024 * 1024)
431
+ return `${Math.round(bytes / 1024)} KB`;
432
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
433
+ }
434
+ const XIcon = () => (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", children: _jsx("path", { d: "M18 6 6 18M6 6l12 12" }) }));
435
+ /** The files attached so far: images as thumbnails, anything else as a chip. Takes no room while there are none. */
436
+ function PromptAttachments({ className, 'aria-label': ariaLabel = 'Attached files', ...props }) {
437
+ const prompt = usePrompt('Prompt.Attachments');
438
+ const slots = promptVariants();
439
+ if (prompt.attachments.length === 0)
440
+ return null;
441
+ return (_jsx("ul", { "data-slot": "prompt-attachments", "aria-label": ariaLabel, className: slots.attachments({ className }), ...props, children: prompt.attachments.map(({ id, file, preview }) => (_jsxs("li", { className: slots.attachment({ className: preview ? slots.attachmentImage() : slots.attachmentFile() }), children: [preview ? (_jsx("img", { src: preview, alt: file.name, className: "size-full rounded-[inherit] object-cover" })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: slots.attachmentGlyph(), children: _jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" }), _jsx("path", { d: "M14 3v5h5" })] }) }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: slots.attachmentName(), children: file.name }), _jsx("span", { className: slots.attachmentMeta(), children: formatSize(file.size) })] })] })), _jsx("button", { type: "button", "aria-label": `Remove ${file.name}`, className: slots.attachmentRemove(), onClick: () => prompt.removeAttachment(id), children: _jsx(XIcon, {}) })] }, id))) }));
442
+ }
443
+ PromptAttachments.displayName = 'Prompt.Attachments';
444
+ /** The row under the text: tools at the start, the submit button at the end. */
445
+ function PromptFooter({ className, ...props }) {
446
+ return _jsx("div", { "data-slot": "prompt-footer", className: promptVariants().footer({ className }), ...props });
447
+ }
448
+ PromptFooter.displayName = 'Prompt.Footer';
449
+ /** The tools at the start of the footer. Wraps onto a second line where it has to. */
450
+ function PromptTools({ className, ...props }) {
451
+ return _jsx("div", { "data-slot": "prompt-tools", className: promptVariants().tools({ className }), ...props });
452
+ }
453
+ PromptTools.displayName = 'Prompt.Tools';
454
+ /** A round button in the footer, holding an icon. With `pressed`, it switches on and off. */
455
+ function PromptTool({ label, pressed, onPressedChange, showLabel = false, onClick, className, children, variant = 'outline', ...props }) {
456
+ const prompt = usePrompt('Prompt.Tool');
457
+ return (_jsxs(Button, { "data-slot": "prompt-tool", variant: variant, shape: "pill", size: showLabel ? 'md' : 'icon', "aria-label": showLabel ? undefined : label, title: showLabel ? undefined : label, "aria-pressed": pressed, disabled: prompt.disabled, onClick: (event) => {
458
+ onClick?.(event);
459
+ if (!event.defaultPrevented && pressed !== undefined)
460
+ onPressedChange?.(!pressed);
461
+ }, className: promptVariants().tool({ className: [showLabel ? 'px-3' : undefined, className] }), ...props, children: [children, showLabel ? label : null] }));
462
+ }
463
+ PromptTool.displayName = 'Prompt.Tool';
464
+ /** A tool that opens the file picker. Files can also be pasted, or dropped on the field. */
465
+ function PromptAttach({ label = 'Attach files', onClick, children, ...props }) {
466
+ const prompt = usePrompt('Prompt.Attach');
467
+ return (_jsx(PromptTool, { label: label, onClick: (event) => {
468
+ onClick?.(event);
469
+ if (!event.defaultPrevented)
470
+ prompt.openFileDialog();
471
+ }, ...props, children: children ?? _jsx(PaperclipIcon, {}) }));
472
+ }
473
+ PromptAttach.displayName = 'Prompt.Attach';
474
+ /** Chooses the model the message goes to. The button shows the chosen one's icon and name. */
475
+ function PromptModel({ items, value, defaultValue, onValueChange, variant = 'pill', label = 'Model', className, }) {
476
+ const prompt = usePrompt('Prompt.Model');
477
+ const slots = promptVariants();
478
+ return (_jsxs(Select, { items: items, value: value, defaultValue: defaultValue ?? (value === undefined ? items[0]?.value : undefined), onValueChange: (next) => {
479
+ if (typeof next === 'string')
480
+ onValueChange?.(next);
481
+ }, disabled: prompt.disabled, children: [_jsx(Select.Trigger, { "aria-label": label, variant: variant, size: "md", className: [variant === 'ghost' ? 'text-foreground' : undefined, className].filter(Boolean).join(' '), children: _jsx(Select.Value, { className: slots.modelValue(), children: (chosen) => {
482
+ const item = items.find((entry) => entry.value === chosen);
483
+ return item ? (_jsxs(_Fragment, { children: [item.icon, _jsx("span", { className: "truncate", children: item.label })] })) : null;
484
+ } }) }), _jsx(Select.Content, { children: items.map((item) => (_jsx(Select.Item, { value: item.value, icon: item.icon, description: item.description, children: item.label }, item.value))) })] }));
485
+ }
486
+ PromptModel.displayName = 'Prompt.Model';
487
+ const PaperclipIcon = () => (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m16 6-8.41 8.59a2 2 0 0 0 2.83 2.83l8.41-8.59a4 4 0 0 0-5.66-5.66l-8.58 8.59a6 6 0 1 0 8.49 8.49L21.5 12" }) }));
488
+ /**
489
+ * One round button that opens a menu of everything that can be added to the
490
+ * message: files, tools, modes. It keeps the footer to one button however
491
+ * many there are.
492
+ */
493
+ function PromptMenu({ label = 'Add', icon, children, side = 'top', align = 'start', width = 'auto', className, ...props }) {
494
+ const prompt = usePrompt('Prompt.Menu');
495
+ return (_jsxs(Menu, { children: [_jsx(Menu.Trigger, { disabled: prompt.disabled, "aria-label": label, title: label, render: _jsx(Button, { "data-slot": "prompt-menu", variant: "outline", shape: "pill", size: "icon" }), className: promptVariants().menuTrigger(), children: icon ?? (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: _jsx("path", { d: "M12 5v14M5 12h14" }) })) }), _jsx(Menu.Content, { side: side, align: align, anchor: width === 'field' ? prompt.fieldRef : undefined, className: [width === 'field' ? 'w-(--anchor-width) rounded-2xl p-1.5' : undefined, className].filter(Boolean).join(' ') || undefined, ...props, children: children })] }));
496
+ }
497
+ PromptMenu.displayName = 'Prompt.Menu';
498
+ /** The menu item that opens the file picker. */
499
+ function PromptAttachItem({ children = 'Add files', shortcut = '⌘U', onClick, ...props }) {
500
+ const prompt = usePrompt('Prompt.AttachItem');
501
+ return (_jsxs(Menu.Item, { onClick: (event) => {
502
+ onClick?.(event);
503
+ if (!event.defaultPrevented)
504
+ prompt.openFileDialog();
505
+ }, ...props, children: [_jsx(PaperclipIcon, {}), children, shortcut === null ? null : _jsx(Menu.Shortcut, { children: shortcut })] }));
506
+ }
507
+ PromptAttachItem.displayName = 'Prompt.AttachItem';
508
+ /* ----------------------------------------------------------------- submit */
509
+ /** Five bars that move while listening, and rest at fixed heights otherwise. */
510
+ function Waveform({ listening }) {
511
+ const bars = useRef([]);
512
+ const resting = [6, 12, 16, 10, 6];
513
+ useEffect(() => {
514
+ if (!listening || window.matchMedia('(prefers-reduced-motion: reduce)').matches)
515
+ return;
516
+ const animations = bars.current.map((bar, index) => bar?.animate([{ scale: '1 0.35' }, { scale: '1 1.15' }, { scale: '1 0.35' }], {
517
+ duration: 700 + index * 90,
518
+ delay: index * -160,
519
+ iterations: Infinity,
520
+ easing: 'ease-in-out',
521
+ }));
522
+ return () => animations.forEach((animation) => animation?.cancel());
523
+ }, [listening]);
524
+ return (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "currentColor", children: resting.map((height, index) => (_jsx("rect", { ref: (node) => {
525
+ bars.current[index] = node;
526
+ }, x: 3.25 + index * 4, y: 12 - height / 2, width: "1.75", height: height, rx: "0.875", style: { transformOrigin: 'center', transformBox: 'fill-box' } }, index))) }));
527
+ }
528
+ /** Sends the message. While busy it stops the answer, and with `onVoice` it starts voice while the field is empty. */
529
+ function PromptSubmit({ onVoice, listening = false, sendLabel = 'Send', stopLabel = 'Stop', voiceLabel = 'Speak', onClick, className, disabled, ...props }) {
530
+ const prompt = usePrompt('Prompt.Submit');
531
+ const slots = promptVariants();
532
+ const mode = prompt.busy ? 'stop' : prompt.empty && onVoice ? 'voice' : 'send';
533
+ const label = mode === 'stop' ? stopLabel : mode === 'voice' ? voiceLabel : sendLabel;
534
+ const off = disabled || prompt.disabled || (mode === 'send' && prompt.empty) || (mode === 'stop' && !prompt.hasStop);
535
+ // Each icon is always there and only the one for the mode is shown, so they
536
+ // can cross-fade in place.
537
+ const shown = (which) => mode === which ? 'opacity-100 scale-100 rotate-0' : 'opacity-0 scale-50 -rotate-45';
538
+ return (_jsxs(Button, { "data-slot": "prompt-submit", "data-mode": mode, type: mode === 'send' ? 'submit' : 'button', "aria-label": label, "aria-pressed": mode === 'voice' ? listening : undefined, disabled: off, onClick: (event) => {
539
+ onClick?.(event);
540
+ if (event.defaultPrevented)
541
+ return;
542
+ if (mode === 'stop')
543
+ prompt.stop();
544
+ if (mode === 'voice')
545
+ onVoice?.();
546
+ }, className: slots.submit({ className }), ...props, children: [_jsx("span", { className: slots.submitIcon({ className: shown('send') }), children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.25", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 19V5M5 12l7-7 7 7" }) }) }), _jsx("span", { className: slots.submitIcon({ className: shown('stop') }), children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("rect", { x: "7", y: "7", width: "10", height: "10", rx: "2" }) }) }), _jsx("span", { className: slots.submitIcon({ className: shown('voice') }), children: _jsx(Waveform, { listening: mode === 'voice' && listening }) }), _jsxs("svg", { "aria-hidden": "true", viewBox: "0 0 46 46", fill: "none", className: slots.submitRing({ className: mode === 'stop' ? 'opacity-100' : 'opacity-0' }), children: [_jsx("circle", { cx: "23", cy: "23", r: "21.5", stroke: "currentColor", strokeOpacity: "0.15", strokeWidth: "1.5" }), _jsx("circle", { cx: "23", cy: "23", r: "21.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeDasharray: "30 105", className: "origin-center animate-spin motion-reduce:animate-none [animation-duration:1.1s]" })] })] }));
547
+ }
548
+ PromptSubmit.displayName = 'Prompt.Submit';
549
+ /* ------------------------------------------------------------------ tasks */
550
+ /** A filled circle in the success colour with a tick drawn in it. */
551
+ const CheckCircle = ({ className }) => (_jsxs("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", className: className, children: [_jsx("path", { d: "M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z", fill: "var(--success)", stroke: "var(--success)", strokeWidth: "1.5" }), _jsx("path", { d: "M8 12.5L10.5 15L16 9", stroke: "white", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round" })] }));
552
+ function reducedMotion() {
553
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
554
+ }
555
+ /** A ring that fills as tasks are done, and becomes a check when all of them are. */
556
+ function ProgressRing({ done, total }) {
557
+ const complete = total > 0 && done === total;
558
+ const r = 6.25;
559
+ const length = 2 * Math.PI * r;
560
+ if (complete) {
561
+ return _jsx(CheckCircle, { className: "size-4" });
562
+ }
563
+ return (_jsxs("svg", { "aria-hidden": "true", viewBox: "0 0 16 16", className: "size-4 -rotate-90", children: [_jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", stroke: "currentColor", strokeOpacity: "0.2", strokeWidth: "2" }), _jsx("circle", { cx: "8", cy: "8", r: r, fill: "none", stroke: "var(--success)", strokeWidth: "2", strokeLinecap: "round", strokeDasharray: `${(length * done) / Math.max(total, 1)} ${length}`, className: "transition-[stroke-dasharray] duration-500 ease-out motion-reduce:transition-none" })] }));
564
+ }
565
+ /**
566
+ * A band above the field with an agent's tasks: one line with a ring that
567
+ * fills as they are done, opening into the list. The list scrolls past
568
+ * `maxHeight`, and keeps the task in progress in view.
569
+ */
570
+ function PromptTasks({ open = true, title, expanded, defaultExpanded = false, onExpandedChange, maxHeight = '10rem', className, children, ...props }) {
571
+ const slots = promptVariants();
572
+ const [inner, setInner] = useState(defaultExpanded);
573
+ const isExpanded = expanded ?? inner;
574
+ const listId = useId();
575
+ const list = useRef(null);
576
+ const tasks = Children.toArray(children).filter(isValidElement);
577
+ const done = tasks.filter((task) => task.props.status === 'done').length;
578
+ const active = tasks.findIndex((task) => task.props.status === 'active');
579
+ // The task in progress is kept in the middle of the list, so a long run can
580
+ // be followed without scrolling. The list scrolls, never the page.
581
+ useEffect(() => {
582
+ const node = list.current;
583
+ const current = node?.querySelector('[data-status=active]');
584
+ if (!node || !current || !isExpanded)
585
+ return;
586
+ node.scrollTo({
587
+ top: current.offsetTop - node.clientHeight / 2 + current.offsetHeight / 2,
588
+ behavior: reducedMotion() ? 'auto' : 'smooth',
589
+ });
590
+ }, [active, isExpanded]);
591
+ const toggle = () => {
592
+ if (expanded === undefined)
593
+ setInner(!isExpanded);
594
+ onExpandedChange?.(!isExpanded);
595
+ };
596
+ return (_jsxs(Band, { edge: "top", open: open, slot: "prompt-tasks", className: ['flex-col items-stretch gap-0 px-0 pt-0.5', className].filter(Boolean).join(' '), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isExpanded, "aria-controls": listId, onClick: toggle, className: slots.tasksHeader(), children: [_jsx(ProgressRing, { done: done, total: tasks.length }), _jsx("span", { className: slots.tasksTitle(), children: title }), _jsxs("span", { className: slots.tasksCount(), children: [done, "/", tasks.length] }), _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", className: slots.tasksChevron({ className: isExpanded ? 'rotate-180' : undefined }), children: _jsx("path", { d: "m6 9 6 6 6-6" }) })] }), _jsx("div", { className: slots.tasksBody({ className: isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]' }), children: _jsx("div", { className: "min-h-0 overflow-hidden", children: _jsx("ol", { ref: list, id: listId, "aria-label": "Tasks", className: slots.tasksList(), style: { maxHeight }, children: children }) }) })] }));
597
+ }
598
+ PromptTasks.displayName = 'Prompt.Tasks';
599
+ /** One task in `Prompt.Tasks`. */
600
+ function PromptTask({ status = 'pending', className, children, ...props }) {
601
+ const slots = promptVariants();
602
+ const text = useShimmer(status === 'active');
603
+ return (_jsxs("li", { "data-status": status, className: slots.task({ className }), ...props, children: [_jsx("span", { className: slots.taskMark(), children: status === 'done' ? (_jsx(CheckCircle, { className: slots.taskDone() })) : status === 'active' ? (_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.75", strokeLinecap: "round", className: "size-3.5 animate-spin text-foreground motion-reduce:animate-none", children: _jsx("path", { d: "M21 12a9 9 0 1 1-6.22-8.56" }) })) : status === 'error' ? (_jsx("span", { className: slots.taskError(), children: "!" })) : (_jsx("span", { className: slots.taskPending() })) }), _jsx("span", { ref: text, className: status === 'active'
604
+ ? slots.statusText({ className: 'whitespace-normal' })
605
+ : status === 'done'
606
+ ? 'text-muted-foreground line-through decoration-muted-foreground/40'
607
+ : status === 'error'
608
+ ? 'text-destructive'
609
+ : 'text-muted-foreground', children: children }), _jsx("span", { className: "sr-only", children: status === 'done' ? ', done' : status === 'active' ? ', in progress' : status === 'error' ? ', failed' : '' })] }));
610
+ }
611
+ PromptTask.displayName = 'Prompt.Task';
612
+ /**
613
+ * A band above the field listing commands while the text is a trigger and a
614
+ * word, `/sum`. The arrow keys move through it, Enter or Tab chooses, Escape
615
+ * puts it away.
616
+ */
617
+ function PromptCommands({ items, trigger = '/', onSelect, label = 'Commands', maxHeight = '12rem' }) {
618
+ const prompt = usePrompt('Prompt.Commands');
619
+ const slots = promptVariants();
620
+ const listId = useId();
621
+ const [activeIndex, setActiveIndex] = useState(0);
622
+ const [dismissed, setDismissed] = useState(null);
623
+ const list = useRef(null);
624
+ const query = prompt.value.startsWith(trigger) && !/\s/.test(prompt.value) ? prompt.value.slice(trigger.length).toLowerCase() : null;
625
+ const matches = query === null
626
+ ? []
627
+ : items
628
+ .filter((item) => item.value.toLowerCase().includes(query) || String(item.label ?? '').toLowerCase().includes(query))
629
+ // Those whose name starts with what was typed come first.
630
+ .sort((a, b) => Number(!a.value.toLowerCase().startsWith(query)) - Number(!b.value.toLowerCase().startsWith(query)));
631
+ const open = matches.length > 0 && dismissed !== prompt.value;
632
+ const active = Math.min(activeIndex, Math.max(matches.length - 1, 0));
633
+ const optionId = (index) => `${listId}-${index}`;
634
+ // A new query starts at the top of what it matches.
635
+ useEffect(() => setActiveIndex(0), [query]);
636
+ const choose = (item) => {
637
+ if (onSelect) {
638
+ onSelect(item);
639
+ prompt.setValue('');
640
+ }
641
+ else {
642
+ prompt.setValue(`${trigger}${item.value} `);
643
+ }
644
+ };
645
+ // The field's keys come here first while the list is open.
646
+ useEffect(() => {
647
+ prompt.keyHandler.current = (event) => {
648
+ if (!open)
649
+ return false;
650
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
651
+ event.preventDefault();
652
+ const step = event.key === 'ArrowDown' ? 1 : -1;
653
+ setActiveIndex((active + step + matches.length) % matches.length);
654
+ return true;
655
+ }
656
+ if ((event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) || event.key === 'Tab') {
657
+ event.preventDefault();
658
+ choose(matches[active]);
659
+ return true;
660
+ }
661
+ if (event.key === 'Escape') {
662
+ event.preventDefault();
663
+ setDismissed(prompt.value);
664
+ return true;
665
+ }
666
+ return false;
667
+ };
668
+ return () => {
669
+ prompt.keyHandler.current = null;
670
+ };
671
+ });
672
+ // The field points a screen reader at the highlighted command.
673
+ const { setListbox } = prompt;
674
+ useEffect(() => {
675
+ setListbox(open ? { id: listId, active: optionId(active) } : null);
676
+ return () => setListbox(null);
677
+ // eslint-disable-next-line react-hooks/exhaustive-deps
678
+ }, [open, active, listId, setListbox]);
679
+ // The highlighted row is kept in view inside the list.
680
+ useEffect(() => {
681
+ const node = list.current;
682
+ const row = node?.querySelector('[aria-selected=true]');
683
+ if (!node || !row)
684
+ return;
685
+ if (row.offsetTop < node.scrollTop)
686
+ node.scrollTop = row.offsetTop;
687
+ else if (row.offsetTop + row.offsetHeight > node.scrollTop + node.clientHeight) {
688
+ node.scrollTop = row.offsetTop + row.offsetHeight - node.clientHeight;
689
+ }
690
+ }, [active]);
691
+ return (_jsxs(Band, { edge: "top", open: open, slot: "prompt-commands", className: "flex-col items-stretch gap-0 px-0 pt-0", children: [_jsx("div", { className: slots.commandsLabel(), children: label }), _jsx("div", { ref: list, id: listId, role: "listbox", "aria-label": typeof label === 'string' ? label : 'Commands', className: slots.commandsList({ className: 'relative' }), style: { maxHeight }, children: matches.map((item, index) => (_jsxs("div", { id: optionId(index), role: "option", "aria-selected": index === active, className: slots.command(),
692
+ // Pressed without taking focus from the field.
693
+ onMouseDown: (event) => event.preventDefault(), onMouseMove: () => index !== active && setActiveIndex(index), onClick: () => choose(item), children: [item.icon, _jsx("span", { className: slots.commandName(), children: item.label ?? `${trigger}${item.value}` }), item.description ? _jsx("span", { className: slots.commandDescription(), children: item.description }) : null] }, item.value))) })] }));
694
+ }
695
+ PromptCommands.displayName = 'Prompt.Commands';
696
+ /* --------------------------------------------------------------- recorder */
697
+ /** Enough history to fill a wide field; only as many bars as fit are drawn. */
698
+ const BARS = 160;
699
+ /** A bar and the gap after it, in pixels: `w-[3px]` and `gap-[2px]`. */
700
+ const BAR_PITCH = 5;
701
+ /**
702
+ * Takes the place of the text and the footer while voice is recorded: what
703
+ * has been heard so far, the sound as a wave scrolling to the left, how long
704
+ * it has been, and buttons to throw it away or keep it.
705
+ */
706
+ function PromptRecorder({ transcript, placeholder = 'Listening…', onCancel, onDone, level, cancelLabel = 'Cancel recording', doneLabel = 'Finish recording', className, ...props }) {
707
+ const slots = promptVariants();
708
+ // Starts on a low murmur rather than flat silence, so the wave is never a row of dots.
709
+ const [levels, setLevels] = useState(() => Array.from({ length: BARS }, (_, i) => 0.12 + 0.06 * Math.sin(i * 1.7)));
710
+ const [seconds, setSeconds] = useState(0);
711
+ const latest = useRef(level);
712
+ latest.current = level;
713
+ const listening = useShimmer(!transcript);
714
+ // The wave fills its row from the cross to the timer, however wide that is.
715
+ const wave = useRef(null);
716
+ const [fits, setFits] = useState(48);
717
+ useEffect(() => {
718
+ const node = wave.current;
719
+ if (!node || typeof ResizeObserver === 'undefined')
720
+ return;
721
+ const observer = new ResizeObserver(([entry]) => {
722
+ setFits(Math.max(8, Math.min(BARS, Math.floor((entry.contentRect.width + 2) / BAR_PITCH))));
723
+ });
724
+ observer.observe(node);
725
+ return () => observer.disconnect();
726
+ }, []);
727
+ useEffect(() => {
728
+ const started = Date.now();
729
+ const clock = window.setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 250);
730
+ if (reducedMotion() && latest.current === undefined) {
731
+ setLevels(Array.from({ length: BARS }, (_, i) => 0.25 + 0.2 * Math.sin(i / 3)));
732
+ return () => window.clearInterval(clock);
733
+ }
734
+ let t = 0;
735
+ let smooth = 0.2;
736
+ const tick = window.setInterval(() => {
737
+ t += 1;
738
+ // Speech: phrases that swell and fall, with syllables riding on them.
739
+ const phrase = Math.max(0, Math.sin(t / 11) * 0.6 + 0.45);
740
+ const syllable = Math.abs(Math.sin(t * 0.85 + Math.sin(t * 0.31) * 2));
741
+ const target = latest.current ?? Math.min(1, 0.1 + phrase * (0.3 + syllable * 0.6) + Math.random() * 0.12);
742
+ smooth += (target - smooth) * 0.55;
743
+ setLevels((current) => [...current.slice(1), smooth]);
744
+ }, 70);
745
+ return () => {
746
+ window.clearInterval(clock);
747
+ window.clearInterval(tick);
748
+ };
749
+ }, []);
750
+ const time = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
751
+ return (_jsxs("div", { "data-slot": "prompt-recorder", className: slots.recorder({ className }), ...props, children: [_jsx("p", { "aria-live": "polite", className: slots.recorderTranscript(), children: transcript || (_jsx("span", { ref: listening, className: slots.recorderListening(), children: placeholder })) }), _jsxs("div", { className: slots.recorderControls(), children: [_jsx(Button, { variant: "ghost", size: "icon", shape: "pill", "aria-label": cancelLabel, onClick: onCancel, children: _jsx(XIcon, {}) }), _jsx("div", { ref: wave, "aria-hidden": "true", className: slots.recorderWave(), children: levels.slice(-fits).map((value, index) => (_jsx("span", { className: slots.recorderBar(), style: { height: `${3 + value * 33}px` } }, index))) }), _jsxs("span", { role: "timer", "aria-label": `Recording, ${time}`, className: slots.recorderTime(), children: [_jsx("span", { className: slots.recorderDot() }), time] }), _jsx(Button, { shape: "pill", "aria-label": doneLabel, onClick: onDone, className: slots.recorderDone(), children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.25", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M20 6 9 17l-5-5" }) }) })] })] }));
752
+ }
753
+ PromptRecorder.displayName = 'Prompt.Recorder';
754
+ export const Prompt = Object.assign(PromptRoot, {
755
+ Banner: PromptBanner,
756
+ BannerAction: PromptBannerAction,
757
+ Field: PromptField,
758
+ Attachments: PromptAttachments,
759
+ Textarea: PromptTextarea,
760
+ Footer: PromptFooter,
761
+ Tools: PromptTools,
762
+ Tool: PromptTool,
763
+ Attach: PromptAttach,
764
+ Menu: PromptMenu,
765
+ AttachItem: PromptAttachItem,
766
+ Model: PromptModel,
767
+ Submit: PromptSubmit,
768
+ Status: PromptStatus,
769
+ Tasks: PromptTasks,
770
+ Task: PromptTask,
771
+ Commands: PromptCommands,
772
+ Recorder: PromptRecorder,
773
+ Context: PromptContext,
774
+ ContextItem: PromptContextItem,
775
+ Hint: PromptHint,
776
+ });
777
+ /* By name as well, for a Server Component, which cannot dot into a client
778
+ module. */
779
+ export { PromptBanner, PromptBannerAction, PromptField, PromptAttachments, PromptTextarea, PromptFooter, PromptTools, PromptTool, PromptAttach, PromptMenu, PromptAttachItem, PromptModel, PromptSubmit, PromptStatus, PromptTasks, PromptTask, PromptCommands, PromptRecorder, PromptContext, PromptContextItem, PromptHint, };