Prompt Input
The composer: auto-growing textarea, attachments, header and footer tools, model and mode pickers, submit with streaming status.
import { Attachment, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments,} from "@/components/ai/attachments";import { PromptInput, PromptInputActionAddAttachments, PromptInputActionAddScreenshot, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuTrigger, PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputTools, usePromptInputAttachments,} from "@/components/ai/prompt-input";import { useState } from "react";import { IconPlaceholder } from "@/edmi/icon-placeholder";
function Files() { const attachments = usePromptInputAttachments(); return ( <Attachments variant="inline"> {attachments.files.map((f) => ( <Attachment key={f.id} data={f} onRemove={() => attachments.remove(f.id)} > <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> ))} </Attachments> );}
export default function Demo() { const [status, setStatus] = useState<"ready" | "submitted" | "streaming">( "ready", );
const submit = () => { setStatus("submitted"); setTimeout(() => setStatus("streaming"), 600); setTimeout(() => setStatus("ready"), 2400); };
return ( <PromptInput onSubmit={submit} className="max-w-xl" multiple> <PromptInputHeader> <Files /> </PromptInputHeader> <PromptInputBody> <PromptInputTextarea placeholder="Ask anything..." /> </PromptInputBody> <PromptInputFooter> <PromptInputTools> <PromptInputActionMenu> <PromptInputActionMenuTrigger /> <PromptInputActionMenuContent> <PromptInputActionAddAttachments /> <PromptInputActionAddScreenshot /> </PromptInputActionMenuContent> </PromptInputActionMenu> <PromptInputButton> <IconPlaceholder lucide="GlobeIcon" tabler="IconWorld" hugeicons="Globe02Icon" phosphor="GlobeIcon" remixicon="RiGlobalLine" /> <span>Search</span> </PromptInputButton> </PromptInputTools> <PromptInputSubmit status={status} onStop={() => setStatus("ready")} /> </PromptInputFooter> </PromptInput> );}<script setup lang="ts">import type { ChatStatus } from "ai";import { GlobeIcon } from "@lucide/vue";import { ref } from "vue";import { Attachment, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments,} from "@/components/ai/attachments";import { PromptInput, PromptInputActionAddAttachments, PromptInputActionAddScreenshot, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuTrigger, PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputTools,} from "@/components/ai/prompt-input";
const status = ref<ChatStatus>("ready");
function submit() { status.value = "submitted"; setTimeout(() => { status.value = "streaming"; }, 600); setTimeout(() => { status.value = "ready"; }, 2400);}</script>
<template> <PromptInput v-slot="{ files, remove }" class="max-w-xl" multiple @submit="submit"> <PromptInputHeader> <Attachments variant="inline"> <Attachment v-for="f in files" :key="f.id" :data="f" @remove="remove(f.id)"> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> </Attachments> </PromptInputHeader> <PromptInputBody> <PromptInputTextarea placeholder="Ask anything..." /> </PromptInputBody> <PromptInputFooter> <PromptInputTools> <PromptInputActionMenu> <PromptInputActionMenuTrigger /> <PromptInputActionMenuContent> <PromptInputActionAddAttachments /> <PromptInputActionAddScreenshot /> </PromptInputActionMenuContent> </PromptInputActionMenu> <PromptInputButton> <GlobeIcon /> <span>Search</span> </PromptInputButton> </PromptInputTools> <PromptInputSubmit :status="status" @stop="status = 'ready'" /> </PromptInputFooter> </PromptInput></template><script lang="ts"> import { Attachment, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments, } from "$lib/components/ai/attachments"; import { PromptInput, PromptInputActionAddAttachments, PromptInputActionAddScreenshot, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuTrigger, PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputTools, } from "$lib/components/ai/prompt-input"; import type { ChatStatus } from "ai"; import IconPlaceholder from "$lib/components/icon-placeholder/icon-placeholder.svelte";
let status = $state<ChatStatus>("ready");
function submit() { status = "submitted"; setTimeout(() => (status = "streaming"), 600); setTimeout(() => (status = "ready"), 2400); }</script>
<PromptInput class="max-w-xl" multiple onSubmit={submit}> {#snippet children({ files, remove })} <PromptInputHeader> <Attachments variant="inline"> {#each files as f (f.id)} <Attachment data={f} onRemove={() => remove(f.id)}> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> {/each} </Attachments> </PromptInputHeader> <PromptInputBody> <PromptInputTextarea placeholder="Ask anything..." /> </PromptInputBody> <PromptInputFooter> <PromptInputTools> <PromptInputActionMenu> <PromptInputActionMenuTrigger /> <PromptInputActionMenuContent> <PromptInputActionAddAttachments /> <PromptInputActionAddScreenshot /> </PromptInputActionMenuContent> </PromptInputActionMenu> <PromptInputButton> <IconPlaceholder lucide="GlobeIcon" tabler="IconWorld" hugeicons="Globe02Icon" phosphor="GlobeIcon" remixicon="RiGlobalLine" /> <span>Search</span> </PromptInputButton> </PromptInputTools> <PromptInputSubmit {status} onStop={() => (status = "ready")} /> </PromptInputFooter> {/snippet}</PromptInput>Built on input-group, dropdown-menu, select, command, hover-card, tooltip and spinner.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-prompt-inputpnpm dlx shadcn@latest add @edmi-ui/ai-prompt-inputyarn dlx shadcn@latest add @edmi-ui/ai-prompt-inputbunx shadcn@latest add @edmi-ui/ai-prompt-inputPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install ai nanoidTerminal window pnpm add ai nanoidTerminal window yarn add ai nanoidTerminal window bun add ai nanoidAdd the Edmi components this one builds on:
command,dropdown-menu,hover-card,input-group,select,spinner,tooltip,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-prompt-inputpnpm dlx shadcn-vue@latest add @edmi-ui/ai-prompt-inputyarn dlx shadcn-vue@latest add @edmi-ui/ai-prompt-inputbunx shadcn-vue@latest add @edmi-ui/ai-prompt-inputPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install ai nanoid @lucide/vue reka-ui @vueuse/coreTerminal window pnpm add ai nanoid @lucide/vue reka-ui @vueuse/coreTerminal window yarn add ai nanoid @lucide/vue reka-ui @vueuse/coreTerminal window bun add ai nanoid @lucide/vue reka-ui @vueuse/coreAdd the Edmi components this one builds on:
command,dropdown-menu,hover-card,input-group,select,spinner,tooltip,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-prompt-input.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
command,dropdown-menu,hover-card,input-group,select,spinner,tooltip,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Attachments } from "@/components/ai/attachments";
<Attachments variant="inline"></Attachments><script setup lang="ts">import { Attachment, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments,} from "@/components/ai/attachments";import { PromptInput, PromptInputBody, PromptInputHeader, PromptInputTextarea,} from "@/components/ai/prompt-input";</script>
<template> <PromptInput v-slot="{ files, remove }" class="max-w-xl" multiple @submit="submit"> <PromptInputHeader> <Attachments variant="inline"> <Attachment v-for="f in files" :key="f.id" :data="f" @remove="remove(f.id)"> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> </Attachments> </PromptInputHeader> <PromptInputBody> <PromptInputTextarea placeholder="Ask anything..." /> </PromptInputBody> <!-- … --> </PromptInput></template><script lang="ts"> import { Attachments } from "$lib/components/ai/attachments"; import { PromptInput, PromptInputActionMenu, PromptInputBody, PromptInputButton, PromptInputFooter, PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputTools, } from "$lib/components/ai/prompt-input";</script>
<PromptInput class="max-w-xl" multiple onSubmit={submit}> {#snippet children({ files, remove })} <PromptInputHeader> <Attachments variant="inline"> {#each files as f (f.id)} <!-- … --> {/each} </Attachments> </PromptInputHeader> <PromptInputBody> <PromptInputTextarea placeholder="Ask anything..." /> </PromptInputBody> <PromptInputFooter> <PromptInputTools> <PromptInputActionMenu> <!-- … --> </PromptInputActionMenu> <PromptInputButton> <!-- … --> </PromptInputButton> </PromptInputTools> <PromptInputSubmit /> </PromptInputFooter> {/snippet}</PromptInput>Component source
Section titled “Component source”"use client";
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";import { cn } from "cn";import { nanoid } from "nanoid";import type { ChangeEvent, ChangeEventHandler, ClipboardEventHandler, ComponentProps, FormEvent, FormEventHandler, HTMLAttributes, KeyboardEventHandler, PropsWithChildren, ReactNode, RefObject,} from "react";import { Children, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,} from "react";import { ImageIcon, MonitorIcon, PlusIcon, ArrowUpIcon, StopIcon, XIcon } from "@phosphor-icons/react";import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator,} from "@/registry/edmi/ui/command";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "@/registry/edmi/ui/dropdown-menu";import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";import { HoverCard, HoverCardContent, HoverCardTrigger,} from "@/registry/edmi/ui/hover-card";import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea,} from "@/registry/edmi/ui/input-group";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/registry/edmi/ui/select";import { Spinner } from "@/registry/edmi/ui/spinner";import { Tooltip, TooltipContent, TooltipTrigger,} from "@/registry/edmi/ui/tooltip";
// ============================================================================// Helpers// ============================================================================
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => { try { const response = await fetch(url); const blob = await response.blob(); // FileReader uses callback-based API, wrapping in Promise is necessary return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(blob); }); } catch { return null; }};
const captureScreenshot = async (): Promise<File | null> => { if ( typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia ) { return null; }
let stream: MediaStream | null = null; const video = document.createElement("video"); video.muted = true; video.playsInline = true;
try { stream = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true, });
video.srcObject = stream;
// Video element uses callback-based API, wrapping in Promise is necessary await new Promise<void>((resolve, reject) => { video.onloadedmetadata = () => resolve(); video.onerror = () => reject(new Error("Failed to load screen stream")); });
await video.play();
const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { return null; }
const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) { return null; }
context.drawImage(video, 0, 0, width, height); // canvas.toBlob uses callback-based API, wrapping in Promise is necessary const blob = await new Promise<Blob | null>((resolve) => { canvas.toBlob(resolve, "image/png"); }); if (!blob) { return null; }
const timestamp = new Date() .toISOString() .replaceAll(/[:.]/g, "-") .replace("T", "_") .replace("Z", "");
return new File([blob], `screenshot-${timestamp}.png`, { lastModified: Date.now(), type: "image/png", }); } finally { if (stream) { for (const track of stream.getTracks()) { track.stop(); } } video.pause(); video.srcObject = null; }};
// ============================================================================// Provider Context & Types// ============================================================================
export interface AttachmentsContext { files: (FileUIPart & { id: string })[]; add: (files: File[] | FileList) => void; remove: (id: string) => void; clear: () => void; openFileDialog: () => void; fileInputRef: RefObject<HTMLInputElement | null>;}
export interface TextInputContext { value: string; setInput: (v: string) => void; clear: () => void;}
export interface PromptInputControllerProps { textInput: TextInputContext; attachments: AttachmentsContext; /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: ( ref: RefObject<HTMLInputElement | null>, open: () => void, ) => void;}
const PromptInputController = createContext<PromptInputControllerProps | null>( null,);const ProviderAttachmentsContext = createContext<AttachmentsContext | null>( null,);
export const usePromptInputController = () => { const ctx = useContext(PromptInputController); if (!ctx) { throw new Error( "Wrap your component inside <PromptInputProvider> to use usePromptInputController().", ); } return ctx;};
// Optional variants (do NOT throw). Useful for dual-mode components.const useOptionalPromptInputController = () => useContext(PromptInputController);
export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); if (!ctx) { throw new Error( "Wrap your component inside <PromptInputProvider> to use useProviderAttachments().", ); } return ctx;};
const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext);
export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string;}>;
/** * Optional global provider that lifts PromptInput state outside of PromptInput. * If you don't use it, PromptInput stays fully self-managed. */export const PromptInputProvider = ({ initialInput: initialTextInput = "", children,}: PromptInputProviderProps) => { // ----- textInput state const [textInput, setTextInput] = useState(initialTextInput); const clearInput = useCallback(() => setTextInput(""), []);
// ----- attachments state (global when wrapped) const [attachmentFiles, setAttachmentFiles] = useState< (FileUIPart & { id: string })[] >([]); const fileInputRef = useRef<HTMLInputElement | null>(null); const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => { const incoming = [...files]; if (incoming.length === 0) { return; }
setAttachmentFiles((prev) => [ ...prev, ...incoming.map((file) => ({ filename: file.name, id: nanoid(), mediaType: file.type, type: "file" as const, url: URL.createObjectURL(file), })), ]); }, []);
const remove = useCallback((id: string) => { setAttachmentFiles((prev) => { const found = prev.find((f) => f.id === id); if (found?.url) { URL.revokeObjectURL(found.url); } return prev.filter((f) => f.id !== id); }); }, []);
const clear = useCallback(() => { setAttachmentFiles((prev) => { for (const f of prev) { if (f.url) { URL.revokeObjectURL(f.url); } } return []; }); }, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure) const attachmentsRef = useRef(attachmentFiles);
useEffect(() => { attachmentsRef.current = attachmentFiles; }, [attachmentFiles]);
// Cleanup blob URLs on unmount to prevent memory leaks useEffect( () => () => { for (const f of attachmentsRef.current) { if (f.url) { URL.revokeObjectURL(f.url); } } }, [], );
const openFileDialog = useCallback(() => { openRef.current?.(); }, []);
const attachments = useMemo<AttachmentsContext>( () => ({ add, clear, fileInputRef, files: attachmentFiles, openFileDialog, remove, }), [attachmentFiles, add, remove, clear, openFileDialog], );
const __registerFileInput = useCallback( (ref: RefObject<HTMLInputElement | null>, open: () => void) => { fileInputRef.current = ref.current; openRef.current = open; }, [], );
const controller = useMemo<PromptInputControllerProps>( () => ({ __registerFileInput, attachments, textInput: { clear: clearInput, setInput: setTextInput, value: textInput, }, }), [textInput, clearInput, attachments, __registerFileInput], );
return ( <PromptInputController.Provider value={controller}> <ProviderAttachmentsContext.Provider value={attachments}> {children} </ProviderAttachmentsContext.Provider> </PromptInputController.Provider> );};
// ============================================================================// Component Context & Hooks// ============================================================================
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => { // Prefer local context (inside PromptInput) as it has validation, fall back to provider const provider = useOptionalProviderAttachments(); const local = useContext(LocalAttachmentsContext); const context = local ?? provider; if (!context) { throw new Error( "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider", ); } return context;};
// ============================================================================// Referenced Sources (Local to PromptInput)// ============================================================================
export interface ReferencedSourcesContext { sources: (SourceDocumentUIPart & { id: string })[]; add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; remove: (id: string) => void; clear: () => void;}
export const LocalReferencedSourcesContext = createContext<ReferencedSourcesContext | null>(null);
export const usePromptInputReferencedSources = () => { const ctx = useContext(LocalReferencedSourcesContext); if (!ctx) { throw new Error( "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider", ); } return ctx;};
export type PromptInputActionAddAttachmentsProps = ComponentProps< typeof DropdownMenuItem> & { label?: string;};
export const PromptInputActionAddAttachments = ({ label = "Add photos or files", ...props}: PromptInputActionAddAttachmentsProps) => { const attachments = usePromptInputAttachments();
const handleSelect = useCallback(() => { attachments.openFileDialog(); }, [attachments]);
return ( <DropdownMenuItem {...props} onClick={handleSelect}> <ImageIcon className="size-4" />{" "} {label} </DropdownMenuItem> );};
export type PromptInputActionAddScreenshotProps = ComponentProps< typeof DropdownMenuItem> & { label?: string;};
export const PromptInputActionAddScreenshot = ({ label = "Take screenshot", onClick, ...props}: PromptInputActionAddScreenshotProps) => { const attachments = usePromptInputAttachments();
const handleSelect = useCallback( async (event: React.MouseEvent<HTMLElement>) => { onClick?.(event as never); if (event.defaultPrevented) { return; }
try { const screenshot = await captureScreenshot(); if (screenshot) { attachments.add([screenshot]); } } catch (error) { if ( error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "AbortError") ) { return; } throw error; } }, [onClick, attachments], );
return ( <DropdownMenuItem {...props} onClick={handleSelect}> <MonitorIcon className="size-4" /> {label} </DropdownMenuItem> );};
export interface PromptInputMessage { text: string; files: FileUIPart[];}
export type PromptInputProps = Omit< HTMLAttributes<HTMLFormElement>, "onSubmit" | "onError"> & { // e.g., "image/*" or leave undefined for any accept?: string; multiple?: boolean; // When true, accepts drops anywhere on document. Default false (opt-in). globalDrop?: boolean; // Render a hidden input with given name and keep it in sync for native form posts. Default false. syncHiddenInput?: boolean; // Minimal constraints maxFiles?: number; // bytes maxFileSize?: number; onError?: (err: { code: "max_files" | "max_file_size" | "accept"; message: string; }) => void; onSubmit: ( message: PromptInputMessage, event: FormEvent<HTMLFormElement>, ) => void | Promise<void>; /** ✦ depth of the composer plate (overlay role: floating in layered mode). The submit button follows the Button rules. */ elevation?: Elevation;};
export const PromptInput = ({ className, elevation, accept, multiple, globalDrop, syncHiddenInput, maxFiles, maxFileSize, onError, onSubmit, children, ...props}: PromptInputProps) => { const level = useElevation(elevation, "overlay"); // Try to use a provider controller if present const controller = useOptionalPromptInputController(); const usingProvider = !!controller;
// Refs const inputRef = useRef<HTMLInputElement | null>(null); const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider) const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); const files = usingProvider ? controller.attachments.files : items;
// ----- Local referenced sources (always local to PromptInput) const [referencedSources, setReferencedSources] = useState< (SourceDocumentUIPart & { id: string })[] >([]);
// Keep a ref to files for cleanup on unmount (avoids stale closure) const filesRef = useRef(files);
useEffect(() => { filesRef.current = files; }, [files]);
const openFileDialogLocal = useCallback(() => { inputRef.current?.click(); }, []);
const matchesAccept = useCallback( (f: File) => { if (!accept || accept.trim() === "") { return true; }
const patterns = accept .split(",") .map((s) => s.trim()) .filter(Boolean);
return patterns.some((pattern) => { if (pattern.endsWith("/*")) { // e.g: image/* -> image/ const prefix = pattern.slice(0, -1); return f.type.startsWith(prefix); } return f.type === pattern; }); }, [accept], );
const addLocal = useCallback( (fileList: File[] | FileList) => { const incoming = [...fileList]; const accepted = incoming.filter((f) => matchesAccept(f)); if (incoming.length && accepted.length === 0) { onError?.({ code: "accept", message: "No files match the accepted types.", }); return; } const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true; const sized = accepted.filter(withinSize); if (accepted.length > 0 && sized.length === 0) { onError?.({ code: "max_file_size", message: "All files exceed the maximum size.", }); return; }
setItems((prev) => { const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - prev.length) : undefined; const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized; if (typeof capacity === "number" && sized.length > capacity) { onError?.({ code: "max_files", message: "Too many files. Some were not added.", }); } const next: (FileUIPart & { id: string })[] = []; for (const file of capped) { next.push({ filename: file.name, id: nanoid(), mediaType: file.type, type: "file", url: URL.createObjectURL(file), }); } return [...prev, ...next]; }); }, [matchesAccept, maxFiles, maxFileSize, onError], );
const removeLocal = useCallback( (id: string) => setItems((prev) => { const found = prev.find((file) => file.id === id); if (found?.url) { URL.revokeObjectURL(found.url); } return prev.filter((file) => file.id !== id); }), [], );
// Wrapper that validates files before calling provider's add const addWithProviderValidation = useCallback( (fileList: File[] | FileList) => { const incoming = [...fileList]; const accepted = incoming.filter((f) => matchesAccept(f)); if (incoming.length && accepted.length === 0) { onError?.({ code: "accept", message: "No files match the accepted types.", }); return; } const withinSize = (f: File) => maxFileSize ? f.size <= maxFileSize : true; const sized = accepted.filter(withinSize); if (accepted.length > 0 && sized.length === 0) { onError?.({ code: "max_file_size", message: "All files exceed the maximum size.", }); return; }
const currentCount = files.length; const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - currentCount) : undefined; const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized; if (typeof capacity === "number" && sized.length > capacity) { onError?.({ code: "max_files", message: "Too many files. Some were not added.", }); }
if (capped.length > 0) { controller?.attachments.add(capped); } }, [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller], );
const clearAttachments = useCallback( () => usingProvider ? controller?.attachments.clear() : setItems((prev) => { for (const file of prev) { if (file.url) { URL.revokeObjectURL(file.url); } } return []; }), [usingProvider, controller], );
const clearReferencedSources = useCallback( () => setReferencedSources([]), [], );
const add = usingProvider ? addWithProviderValidation : addLocal; const remove = usingProvider ? controller.attachments.remove : removeLocal; const openFileDialog = usingProvider ? controller.attachments.openFileDialog : openFileDialogLocal;
const clear = useCallback(() => { clearAttachments(); clearReferencedSources(); }, [clearAttachments, clearReferencedSources]);
// Let provider know about our hidden file input so external menus can call openFileDialog() useEffect(() => { if (!usingProvider) { return; } controller.__registerFileInput(inputRef, () => inputRef.current?.click()); }, [usingProvider, controller]);
// Note: File input cannot be programmatically set for security reasons // The syncHiddenInput prop is no longer functional useEffect(() => { if (syncHiddenInput && inputRef.current && files.length === 0) { inputRef.current.value = ""; } }, [files, syncHiddenInput]);
// Attach drop handlers on nearest form and document (opt-in) useEffect(() => { const form = formRef.current; if (!form) { return; } if (globalDrop) { // when global drop is on, let the document-level handler own drops return; }
const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { add(e.dataTransfer.files); } }; form.addEventListener("dragover", onDragOver); form.addEventListener("drop", onDrop); return () => { form.removeEventListener("dragover", onDragOver); form.removeEventListener("drop", onDrop); }; }, [add, globalDrop]);
useEffect(() => { if (!globalDrop) { return; }
const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { if (e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { add(e.dataTransfer.files); } }; document.addEventListener("dragover", onDragOver); document.addEventListener("drop", onDrop); return () => { document.removeEventListener("dragover", onDragOver); document.removeEventListener("drop", onDrop); }; }, [add, globalDrop]);
useEffect( () => () => { if (!usingProvider) { for (const f of filesRef.current) { if (f.url) { URL.revokeObjectURL(f.url); } } } }, [usingProvider], );
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback( (event) => { if (event.currentTarget.files) { add(event.currentTarget.files); } // Reset input value to allow selecting files that were previously removed event.currentTarget.value = ""; }, [add], );
const attachmentsCtx = useMemo<AttachmentsContext>( () => ({ add, clear: clearAttachments, fileInputRef: inputRef, files: files.map((item) => ({ ...item, id: item.id })), openFileDialog, remove, }), [files, add, remove, clearAttachments, openFileDialog], );
const refsCtx = useMemo<ReferencedSourcesContext>( () => ({ add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => { const array = Array.isArray(incoming) ? incoming : [incoming]; setReferencedSources((prev) => [ ...prev, ...array.map((s) => ({ ...s, id: nanoid() })), ]); }, clear: clearReferencedSources, remove: (id: string) => { setReferencedSources((prev) => prev.filter((s) => s.id !== id)); }, sources: referencedSources, }), [referencedSources, clearReferencedSources], );
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback( async (event) => { event.preventDefault();
const form = event.currentTarget; const text = usingProvider ? controller.textInput.value : (() => { const formData = new FormData(form); return (formData.get("message") as string) || ""; })();
// Reset form immediately after capturing text to avoid race condition // where user input during async blob conversion would be lost if (!usingProvider) { form.reset(); }
try { // Convert blob URLs to data URLs asynchronously const convertedFiles: FileUIPart[] = await Promise.all( files.map(async ({ id: _id, ...item }) => { if (item.url?.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); // If conversion failed, keep the original blob URL return { ...item, url: dataUrl ?? item.url, }; } return item; }), );
const result = onSubmit({ files: convertedFiles, text }, event);
// Handle both sync and async onSubmit if (result instanceof Promise) { try { await result; clear(); if (usingProvider) { controller.textInput.clear(); } } catch { // Don't clear on error - user may want to retry } } else { // Sync function completed without throwing, clear inputs clear(); if (usingProvider) { controller.textInput.clear(); } } } catch { // Don't clear on error - user may want to retry } }, [usingProvider, controller, files, onSubmit, clear], );
// Render with or without local provider const inner = ( <> <input accept={accept} aria-label="Upload files" className="hidden" multiple={multiple} onChange={handleChange} ref={inputRef} title="Upload files" type="file" /> <form data-slot="ai-prompt-input" className={cn("w-full", className)} onSubmit={handleSubmit} ref={formRef} {...props} > <InputGroup elevation={level} className="h-auto flex-col overflow-hidden rounded-[calc(var(--radius)*1.6)]" > {children} </InputGroup> </form> </> );
const withReferencedSources = ( <LocalReferencedSourcesContext.Provider value={refsCtx}> {inner} </LocalReferencedSourcesContext.Provider> );
// Always provide LocalAttachmentsContext so children get validated add function return ( <LocalAttachmentsContext.Provider value={attachmentsCtx}> {withReferencedSources} </LocalAttachmentsContext.Provider> );};
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputBody = ({ className, ...props}: PromptInputBodyProps) => ( <div className={cn("contents", className)} {...props} />);
export type PromptInputTextareaProps = ComponentProps< typeof InputGroupTextarea>;
export const PromptInputTextarea = ({ onChange, onKeyDown, className, placeholder = "What would you like to know?", ...props}: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback( (e) => { // Call the external onKeyDown handler first onKeyDown?.(e);
// If the external handler prevented default, don't run internal logic if (e.defaultPrevented) { return; }
if (e.key === "Enter") { if (isComposing || e.nativeEvent.isComposing) { return; } if (e.shiftKey) { return; } e.preventDefault();
// Check if the submit button is disabled before submitting const { form } = e.currentTarget; const submitButton = form?.querySelector( 'button[type="submit"]', ) as HTMLButtonElement | null; if (submitButton?.disabled) { return; }
form?.requestSubmit(); }
// Remove last attachment when Backspace is pressed and textarea is empty if ( e.key === "Backspace" && e.currentTarget.value === "" && attachments.files.length > 0 ) { e.preventDefault(); const lastAttachment = attachments.files.at(-1); if (lastAttachment) { attachments.remove(lastAttachment.id); } } }, [onKeyDown, isComposing, attachments], );
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback( (event) => { const items = event.clipboardData?.items;
if (!items) { return; }
const files: File[] = [];
for (const item of items) { if (item.kind === "file") { const file = item.getAsFile(); if (file) { files.push(file); } } }
if (files.length > 0) { event.preventDefault(); attachments.add(files); } }, [attachments], );
const handleCompositionEnd = useCallback(() => setIsComposing(false), []); const handleCompositionStart = useCallback(() => setIsComposing(true), []);
const controlledProps = controller ? { onChange: (e: ChangeEvent<HTMLTextAreaElement>) => { controller.textInput.setInput(e.currentTarget.value); onChange?.(e); }, value: controller.textInput.value, } : { onChange, };
return ( <InputGroupTextarea className={cn( "field-sizing-content max-h-48 min-h-14 px-3.5 pt-3 pb-1.5 text-sm leading-normal", className, )} name="message" onCompositionEnd={handleCompositionEnd} onCompositionStart={handleCompositionStart} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={placeholder} {...props} {...controlledProps} /> );};
export type PromptInputHeaderProps = Omit< ComponentProps<typeof InputGroupAddon>, "align">;
export const PromptInputHeader = ({ className, ...props}: PromptInputHeaderProps) => ( <InputGroupAddon align="block-start" className={cn( "flex-wrap gap-1.5 px-3 pt-2.5 pb-0 has-[>:empty]:hidden", className, )} {...props} />);
export type PromptInputFooterProps = Omit< ComponentProps<typeof InputGroupAddon>, "align">;
export const PromptInputFooter = ({ className, ...props}: PromptInputFooterProps) => ( <InputGroupAddon align="block-end" className={cn("justify-between gap-1 px-2 pt-1.5 pb-2", className)} {...props} />);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({ className, ...props}: PromptInputToolsProps) => ( <div className={cn("flex min-w-0 items-center gap-1", className)} {...props} />);
export type PromptInputButtonTooltip = | string | { content: ReactNode; shortcut?: string; side?: ComponentProps<typeof TooltipContent>["side"]; };
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & { tooltip?: PromptInputButtonTooltip;};
export const PromptInputButton = ({ variant = "ghost", className, size, tooltip, ...props}: PromptInputButtonProps) => { const hasLabel = Children.count(props.children) > 1; const newSize = size ?? (hasLabel ? "sm" : "icon-sm");
const button = ( <InputGroupButton className={cn( "text-[13px] text-foreground", newSize === "sm" && "h-8 gap-1.5 px-2.5", className, )} size={newSize} type="button" variant={variant} {...props} /> );
if (!tooltip) { return button; }
const tooltipContent = typeof tooltip === "string" ? tooltip : tooltip.content; const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut; const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
return ( <Tooltip> <TooltipTrigger render={button} /> <TooltipContent side={side}> {tooltipContent} {shortcut && ( <span className="ml-2 text-muted-foreground">{shortcut}</span> )} </TooltipContent> </Tooltip> );};
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( <DropdownMenu {...props} />);
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = ({ className, children, ...props}: PromptInputActionMenuTriggerProps) => ( <DropdownMenuTrigger render={<PromptInputButton className={className} {...props} />} > {children ?? ( <PlusIcon className="size-4" /> )} </DropdownMenuTrigger>);
export type PromptInputActionMenuContentProps = ComponentProps< typeof DropdownMenuContent>;export const PromptInputActionMenuContent = ({ className, ...props}: PromptInputActionMenuContentProps) => ( <DropdownMenuContent align="start" className={cn(className)} {...props} />);
export type PromptInputActionMenuItemProps = ComponentProps< typeof DropdownMenuItem>;export const PromptInputActionMenuItem = ({ className, ...props}: PromptInputActionMenuItemProps) => ( <DropdownMenuItem className={cn(className)} {...props} />);
// Note: Actions that perform side-effects (like opening a file dialog)// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & { status?: ChatStatus; onStop?: () => void;};
export const PromptInputSubmit = ({ className, variant, size = "icon-sm", status, onStop, onClick, children, ...props}: PromptInputSubmitProps) => { const isGenerating = status === "submitted" || status === "streaming";
let Icon = ( <ArrowUpIcon className="size-4" /> );
if (status === "submitted") { Icon = <Spinner />; } else if (status === "streaming") { Icon = ( <StopIcon className="size-3.5 fill-current" /> ); } else if (status === "error") { Icon = ( <XIcon className="size-4" /> ); }
const handleClick = useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { if (isGenerating && onStop) { e.preventDefault(); onStop(); return; } onClick?.(e as never); }, [isGenerating, onStop, onClick], );
return ( <InputGroupButton aria-label={isGenerating ? "Stop" : "Submit"} data-slot="ai-prompt-input-submit" data-status={status} className={cn("rounded-[9px]", className)} onClick={handleClick} size={size} type={isGenerating && onStop ? "button" : "submit"} variant={variant ?? (status === "error" ? "destructive" : "default")} {...props} > {children ?? Icon} </InputGroupButton> );};
export type PromptInputSelectProps = ComponentProps<typeof Select>;
export const PromptInputSelect = (props: PromptInputSelectProps) => ( <Select {...props} />);
export type PromptInputSelectTriggerProps = ComponentProps< typeof SelectTrigger>;
export const PromptInputSelectTrigger = ({ className, ...props}: PromptInputSelectTriggerProps) => ( <SelectTrigger className={cn( "h-8 border-none bg-transparent px-2.5 text-[13px] font-medium text-foreground shadow-none transition-colors data-[size=default]:h-8", "hover:bg-accent data-[popup-open]:bg-accent data-[popup-open]:shadow-none", className, )} {...props} />);
export type PromptInputSelectContentProps = ComponentProps< typeof SelectContent>;
export const PromptInputSelectContent = ({ className, ...props}: PromptInputSelectContentProps) => ( <SelectContent className={cn(className)} {...props} />);
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputSelectItem = ({ className, ...props}: PromptInputSelectItemProps) => ( <SelectItem className={cn(className)} {...props} />);
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
export const PromptInputSelectValue = ({ className, ...props}: PromptInputSelectValueProps) => ( <SelectValue className={cn(className)} {...props} />);
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
export const PromptInputHoverCard = (props: PromptInputHoverCardProps) => ( <HoverCard {...props} />);
export type PromptInputHoverCardTriggerProps = ComponentProps< typeof HoverCardTrigger>;
export const PromptInputHoverCardTrigger = ( props: PromptInputHoverCardTriggerProps,) => <HoverCardTrigger {...props} />;
export type PromptInputHoverCardContentProps = ComponentProps< typeof HoverCardContent>;
export const PromptInputHoverCardContent = ({ align = "start", ...props}: PromptInputHoverCardContentProps) => ( <HoverCardContent align={align} {...props} />);
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabsList = ({ className, ...props}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTab = ({ className, ...props}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
export const PromptInputTabLabel = ({ className, ...props}: PromptInputTabLabelProps) => ( // Content provided via children in props <h3 className={cn( "mb-2 px-3 font-medium text-muted-foreground text-xs", className, )} {...props} />);
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabBody = ({ className, ...props}: PromptInputTabBodyProps) => ( <div className={cn("space-y-1", className)} {...props} />);
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabItem = ({ className, ...props}: PromptInputTabItemProps) => ( <div className={cn( "flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", className, )} {...props} />);
export type PromptInputCommandProps = ComponentProps<typeof Command>;
export const PromptInputCommand = ({ className, ...props}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
export const PromptInputCommandInput = ({ className, ...props}: PromptInputCommandInputProps) => ( <CommandInput className={cn(className)} {...props} />);
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
export const PromptInputCommandList = ({ className, ...props}: PromptInputCommandListProps) => ( <CommandList className={cn(className)} {...props} />);
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
export const PromptInputCommandEmpty = ({ className, ...props}: PromptInputCommandEmptyProps) => ( <CommandEmpty className={cn(className)} {...props} />);
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
export const PromptInputCommandGroup = ({ className, ...props}: PromptInputCommandGroupProps) => ( <CommandGroup className={cn(className)} {...props} />);
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
export const PromptInputCommandItem = ({ className, ...props}: PromptInputCommandItemProps) => ( <CommandItem className={cn(className)} {...props} />);
export type PromptInputCommandSeparatorProps = ComponentProps< typeof CommandSeparator>;
export const PromptInputCommandSeparator = ({ className, ...props}: PromptInputCommandSeparatorProps) => ( <CommandSeparator className={cn(className)} {...props} />);<script setup lang="ts">import type { HTMLAttributes } from "vue"import type { PromptInputError, PromptInputMessage } from "./types"import { getCurrentInstance, inject, onBeforeUnmount, onMounted, watchEffect } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation"import { InputGroup } from "@/registry/edmi/ui/input-group"import { createPromptInputState, providePromptInput } from "./context"import { PROMPT_INPUT_KEY } from "./types"
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] /** e.g. `image/*`, `.csv`; leave undefined for any. */ accept?: string multiple?: boolean /** When true, accepts drops anywhere on the document. Default false (opt-in). */ globalDrop?: boolean maxFiles?: number /** Bytes. */ maxFileSize?: number initialInput?: string /** ✦ depth of the composer plate (overlay role: floating in layered mode). The submit button follows the Button rules. */ elevation?: Elevation}>(), { elevation: undefined,})
const level = useElevation(() => props.elevation, "overlay")
const emit = defineEmits<{ (e: "submit", message: PromptInputMessage): void (e: "error", err: PromptInputError): void}>()
// `submit` listeners may be async: read the raw listener so the composer can await it (and keep the// text on failure) instead of treating emit() as fire-and-forget.const instance = getCurrentInstance()function callListener<T>(name: "onSubmit" | "onError", payload: T) { const listener = instance?.vnode.props?.[name] if (Array.isArray(listener)) return Promise.all(listener.map(fn => (typeof fn === "function" ? fn(payload) : undefined))) if (typeof listener === "function") return listener(payload)}
// Inside a PromptInputProvider the state is lifted; otherwise it is local to this composer.const state = inject(PROMPT_INPUT_KEY, null) ?? providePromptInput(createPromptInputState(props.initialInput))
watchEffect(() => { state.options.accept = props.accept state.options.maxFiles = props.maxFiles state.options.maxFileSize = props.maxFileSize})state.options.onSubmit = async (message) => { if (instance?.vnode.props?.onSubmit) await callListener("onSubmit", message) else emit("submit", message)}state.options.onError = (err) => { if (instance?.vnode.props?.onError) void callListener("onError", err) else emit("error", err)}
const { addFiles, submitForm } = state
function handleDragOver(e: DragEvent) { if (e.dataTransfer?.types?.includes("Files")) e.preventDefault()}
function handleDrop(e: DragEvent) { if (e.dataTransfer?.types?.includes("Files")) e.preventDefault() if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files)}
onMounted(() => { if (props.globalDrop) { document.addEventListener("dragover", handleDragOver) document.addEventListener("drop", handleDrop) }})
onBeforeUnmount(() => { document.removeEventListener("dragover", handleDragOver) document.removeEventListener("drop", handleDrop)})
function onFileChange(e: Event) { const input = e.target as HTMLInputElement if (input.files) addFiles(input.files) // Reset so a previously removed file can be picked again. input.value = ""}</script>
<template> <input :ref="(el) => { state.fileInputRef.value = el as HTMLInputElement | null }" type="file" class="hidden" aria-label="Upload files" title="Upload files" :accept="props.accept" :multiple="props.multiple" @change="onFileChange" > <form v-bind="$attrs" data-slot="ai-prompt-input" :class="cn('w-full', props.class)" @submit.prevent="submitForm()" @dragover="!props.globalDrop && handleDragOver($event)" @drop="!props.globalDrop && handleDrop($event)" > <InputGroup :elevation="level" class="h-auto flex-col overflow-hidden rounded-[calc(var(--radius)*1.6)]"> <!-- Slot props expose the composer state (files, remove, text, ...) so children can render attachments. --> <slot :files="state.files.value" :add="state.addFiles" :remove="state.removeFile" :clear="state.clearFiles" :open-file-dialog="state.openFileDialog" :text-input="state.textInput.value" :sources="state.referencedSources.value" /> </InputGroup> </form></template><script setup lang="ts">import { ImageIcon } from "@lucide/vue"import { DropdownMenuItem } from "@/registry/edmi/ui/dropdown-menu"import { usePromptInputAttachments } from "./context"
type Props = InstanceType<typeof DropdownMenuItem>["$props"]interface PromptInputActionAddAttachmentsProps extends /* @vue-ignore */ Props { label?: string}
const props = withDefaults(defineProps<PromptInputActionAddAttachmentsProps>(), { label: "Add photos or files",})
const { openFileDialog } = usePromptInputAttachments()</script>
<template> <DropdownMenuItem @select="openFileDialog"> <ImageIcon class="size-4" /> {{ props.label }} </DropdownMenuItem></template><script setup lang="ts">import { MonitorIcon } from "@lucide/vue"import { DropdownMenuItem } from "@/registry/edmi/ui/dropdown-menu"import { usePromptInputAttachments } from "./context"import { captureScreenshot } from "./screenshot"
type Props = InstanceType<typeof DropdownMenuItem>["$props"]interface PromptInputActionAddScreenshotProps extends /* @vue-ignore */ Props { label?: string}
const props = withDefaults(defineProps<PromptInputActionAddScreenshotProps>(), { label: "Take screenshot",})
const attachments = usePromptInputAttachments()
async function handleSelect(event: Event) { if (event.defaultPrevented) return try { const screenshot = await captureScreenshot() if (screenshot) attachments.add([screenshot]) } catch (error) { // The user dismissed the capture picker: not an error. if (error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "AbortError")) return throw error }}</script>
<template> <DropdownMenuItem @select="handleSelect"> <MonitorIcon class="size-4" /> {{ props.label }} </DropdownMenuItem></template><script setup lang="ts">import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"import { useForwardPropsEmits } from "reka-ui"import { DropdownMenu } from "@/registry/edmi/ui/dropdown-menu"
const props = defineProps<DropdownMenuRootProps>()const emits = defineEmits<DropdownMenuRootEmits>()const forwarded = useForwardPropsEmits(props, emits)</script>
<template> <DropdownMenu v-bind="forwarded"> <slot /> </DropdownMenu></template><script setup lang="ts">import type { DropdownMenuContentProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { reactiveOmit } from "@vueuse/core"import { DropdownMenuContent } from "@/registry/edmi/ui/dropdown-menu"
const props = withDefaults(defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(), { align: "start",})const delegated = reactiveOmit(props, "class")</script>
<template> <DropdownMenuContent v-bind="delegated" :class="props.class"> <slot /> </DropdownMenuContent></template><script setup lang="ts">import { DropdownMenuItem } from "@/registry/edmi/ui/dropdown-menu"
type Props = InstanceType<typeof DropdownMenuItem>["$props"]interface PromptInputActionMenuItemProps extends /* @vue-ignore */ Props {}defineProps<PromptInputActionMenuItemProps>()</script>
<template> <DropdownMenuItem> <slot /> </DropdownMenuItem></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { PlusIcon } from "@lucide/vue"import { DropdownMenuTrigger } from "@/registry/edmi/ui/dropdown-menu"import PromptInputButton from "./PromptInputButton.vue"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <DropdownMenuTrigger as-child> <PromptInputButton :class="props.class"> <slot> <PlusIcon class="size-4" /> </slot> </PromptInputButton> </DropdownMenuTrigger></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div :class="cn('contents', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { InputGroupButtonVariants } from "@/registry/edmi/ui/input-group"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { Comment, computed, Text, useSlots } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { InputGroupButton } from "@/registry/edmi/ui/input-group"import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/registry/edmi/ui/tooltip"
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] variant?: ButtonVariants["variant"] size?: InputGroupButtonVariants["size"] /** Tooltip text; `shortcut` and `side` refine it. */ tooltip?: string shortcut?: string side?: "top" | "right" | "bottom" | "left"}>(), { variant: "ghost", side: "top",})
const slots = useSlots()
// An icon alone is a square button; an icon plus a label is a small text button.const computedSize = computed(() => { if (props.size) return props.size const nodes = (slots.default?.() ?? []).filter((node) => { if (node.type === Comment) return false if (node.type === Text && !node.children?.toString().trim()) return false return true }) return nodes.length > 1 ? "sm" : "icon-sm"})</script>
<template> <TooltipProvider v-if="props.tooltip"> <Tooltip> <TooltipTrigger as-child> <InputGroupButton v-bind="$attrs" type="button" :size="computedSize" :variant="props.variant" :class="cn('text-[13px] text-foreground', computedSize === 'sm' && 'h-8 gap-1.5 px-2.5', props.class)" > <slot /> </InputGroupButton> </TooltipTrigger> <TooltipContent :side="props.side"> {{ props.tooltip }} <span v-if="props.shortcut" class="ml-2 text-muted-foreground">{{ props.shortcut }}</span> </TooltipContent> </Tooltip> </TooltipProvider> <InputGroupButton v-else v-bind="$attrs" type="button" :size="computedSize" :variant="props.variant" :class="cn('text-[13px] text-foreground', computedSize === 'sm' && 'h-8 gap-1.5 px-2.5', props.class)" > <slot /> </InputGroupButton></template><script setup lang="ts">import { Command } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof Command>["$props"]interface PromptInputCommandProps extends /* @vue-ignore */ Props {}defineProps<PromptInputCommandProps>()</script>
<template> <Command> <slot /> </Command></template><script setup lang="ts">import { CommandEmpty } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof CommandEmpty>["$props"]interface PromptInputCommandEmptyProps extends /* @vue-ignore */ Props {}defineProps<PromptInputCommandEmptyProps>()</script>
<template> <CommandEmpty> <slot /> </CommandEmpty></template><script setup lang="ts">import { CommandGroup } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof CommandGroup>["$props"]interface PromptInputCommandGroupProps extends /* @vue-ignore */ Props {}defineProps<PromptInputCommandGroupProps>()</script>
<template> <CommandGroup> <slot /> </CommandGroup></template><script setup lang="ts">import { CommandInput } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof CommandInput>["$props"]interface PromptInputCommandInputProps extends /* @vue-ignore */ Props {}defineProps<PromptInputCommandInputProps>()</script>
<template> <CommandInput> <slot /> </CommandInput></template><script setup lang="ts">import { CommandItem } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof CommandItem>["$props"]interface PromptInputCommandItemProps extends /* @vue-ignore */ Props {}defineOptions({ inheritAttrs: false })defineProps<PromptInputCommandItemProps>()</script>
<template> <CommandItem v-bind="($attrs as any)"> <slot /> </CommandItem></template><script setup lang="ts">import { CommandList } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof CommandList>["$props"]interface PromptInputCommandListProps extends /* @vue-ignore */ Props {}defineProps<PromptInputCommandListProps>()</script>
<template> <CommandList> <slot /> </CommandList></template><script setup lang="ts">import { CommandSeparator } from "@/registry/edmi/ui/command"
type Props = InstanceType<typeof CommandSeparator>["$props"]interface PromptInputCommandSeparatorProps extends /* @vue-ignore */ Props {}defineProps<PromptInputCommandSeparatorProps>()</script>
<template> <CommandSeparator> <slot /> </CommandSeparator></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { InputGroupAddon } from "@/registry/edmi/ui/input-group"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <InputGroupAddon align="block-end" :class="cn('justify-between gap-1 px-2 pt-1.5 pb-2', props.class)" > <slot /> </InputGroupAddon></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { InputGroupAddon } from "@/registry/edmi/ui/input-group"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <InputGroupAddon align="block-start" :class="cn('flex-wrap gap-1.5 px-3 pt-2.5 pb-0 has-[>:empty]:hidden', props.class)" > <slot /> </InputGroupAddon></template><script setup lang="ts">import { HoverCard } from "@/registry/edmi/ui/hover-card"
type Props = InstanceType<typeof HoverCard>["$props"]interface PromptInputHoverCardProps extends /* @vue-ignore */ Props {}defineProps<PromptInputHoverCardProps>()</script>
<template> <HoverCard :open-delay="0" :close-delay="0"> <slot /> </HoverCard></template><script setup lang="ts">import { HoverCardContent } from "@/registry/edmi/ui/hover-card"
type Props = InstanceType<typeof HoverCardContent>["$props"]interface PromptInputHoverCardContentProps extends /* @vue-ignore */ Props {}defineProps<PromptInputHoverCardContentProps>()</script>
<template> <HoverCardContent align="start"> <slot /> </HoverCardContent></template><script setup lang="ts">import { HoverCardTrigger } from "@/registry/edmi/ui/hover-card"
type Props = InstanceType<typeof HoverCardTrigger>["$props"]interface PromptInputHoverCardTriggerProps extends /* @vue-ignore */ Props {}defineProps<PromptInputHoverCardTriggerProps>()</script>
<template> <HoverCardTrigger> <slot /> </HoverCardTrigger></template><script setup lang="ts">import { createPromptInputState, providePromptInput } from "./context"
// Optional global provider that lifts PromptInput state outside of PromptInput.// If you don't use it, PromptInput stays fully self-managed.const props = defineProps<{ initialInput?: string}>()
providePromptInput(createPromptInputState(props.initialInput))</script>
<template> <slot /></template><script setup lang="ts">import type { SelectRootEmits, SelectRootProps } from "reka-ui"import { useForwardPropsEmits } from "reka-ui"import { Select } from "@/registry/edmi/ui/select"
const props = defineProps<SelectRootProps>()const emits = defineEmits<SelectRootEmits>()const forwarded = useForwardPropsEmits(props, emits)</script>
<template> <Select v-bind="forwarded"> <slot /> </Select></template><script setup lang="ts">import { SelectContent } from "@/registry/edmi/ui/select"
type Props = InstanceType<typeof SelectContent>["$props"]interface PromptInputSelectContentProps extends /* @vue-ignore */ Props {}defineProps<PromptInputSelectContentProps>()</script>
<template> <SelectContent> <slot /> </SelectContent></template><script setup lang="ts">import { SelectItem } from "@/registry/edmi/ui/select"
type Props = InstanceType<typeof SelectItem>["$props"]interface PromptInputSelectItemProps extends /* @vue-ignore */ Props {}defineOptions({ inheritAttrs: false })defineProps<PromptInputSelectItemProps>()</script>
<template> <SelectItem v-bind="($attrs as any)"> <slot /> </SelectItem></template><script setup lang="ts">import type { SelectTriggerProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { reactiveOmit } from "@vueuse/core"import { cn } from "@/registry/edmi/lib/utils"import { SelectTrigger } from "@/registry/edmi/ui/select"
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>()const delegated = reactiveOmit(props, "class")</script>
<template> <SelectTrigger v-bind="delegated" :class="cn( 'h-8 border-none bg-transparent px-2.5 text-[13px] font-medium text-foreground shadow-none transition-colors data-[size=default]:h-8', 'hover:bg-accent data-[state=open]:bg-accent data-[state=open]:shadow-none', props.class, )" > <slot /> </SelectTrigger></template><script setup lang="ts">import { SelectValue } from "@/registry/edmi/ui/select"
type Props = InstanceType<typeof SelectValue>["$props"]interface PromptInputSelectValueProps extends /* @vue-ignore */ Props {}defineProps<PromptInputSelectValueProps>()</script>
<template> <SelectValue> <slot /> </SelectValue></template><script setup lang="ts">import type { ChatStatus } from "ai"import type { HTMLAttributes } from "vue"import type { InputGroupButtonVariants } from "@/registry/edmi/ui/input-group"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { ArrowUpIcon, XIcon } from "@lucide/vue"import { computed, getCurrentInstance } from "vue"import { cn } from "@/registry/edmi/lib/utils"import type { Elevation } from "@/registry/edmi/ui/elevation"import { InputGroupButton } from "@/registry/edmi/ui/input-group"import { Spinner } from "@/registry/edmi/ui/spinner"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] status?: ChatStatus variant?: ButtonVariants["variant"] size?: InputGroupButtonVariants["size"] /** ✦ depth: follows the Button rules (raised in layered mode). */ elevation?: Elevation}>(), { size: "icon-sm", elevation: undefined,})
const emit = defineEmits<{ (e: "stop"): void}>()
// With a `stop` listener the button turns into a stop button while generating.const hasStop = !!getCurrentInstance()?.vnode.props?.onStop
const isGenerating = computed(() => props.status === "submitted" || props.status === "streaming")const isStop = computed(() => isGenerating.value && hasStop)
function handleClick(e: MouseEvent) { if (isStop.value) { e.preventDefault() emit("stop") }}</script>
<template> <InputGroupButton data-slot="ai-prompt-input-submit" :data-status="props.status" :aria-label="isGenerating ? 'Stop' : 'Submit'" :type="isStop ? 'button' : 'submit'" :size="props.size" :variant="props.variant ?? (props.status === 'error' ? 'destructive' : 'default')" :elevation="props.elevation" :class="cn('rounded-[9px]', props.class)" @click="handleClick" > <slot> <Spinner v-if="props.status === 'submitted'" /> <span v-else-if="props.status === 'streaming'" class="size-2.5 rounded-[2px] bg-current" /> <XIcon v-else-if="props.status === 'error'" class="size-4" /> <ArrowUpIcon v-else class="size-4" /> </slot> </InputGroupButton></template><script setup lang="ts">import type { HTMLAttributes } from "vue"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div :class="props.class"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div :class="cn('space-y-1', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div :class="cn('flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent', props.class)"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <h3 :class="cn('mb-2 px-3 text-xs font-medium text-muted-foreground', props.class)"> <slot /> </h3></template><script setup lang="ts">import type { HTMLAttributes } from "vue"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div :class="props.class"> <slot /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed, ref } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { InputGroupTextarea } from "@/registry/edmi/ui/input-group"import { usePromptInput } from "./context"
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] placeholder?: string}>(), { placeholder: "What would you like to know?",})
const { textInput, setTextInput, addFiles, files, removeFile } = usePromptInput()const isComposing = ref(false)
function handleKeyDown(e: KeyboardEvent) { if (e.key === "Enter") { if (isComposing.value || e.isComposing || e.shiftKey) return
e.preventDefault()
const textarea = e.currentTarget as HTMLTextAreaElement | null const submitButton = textarea?.form?.querySelector("button[type=\"submit\"]") as HTMLButtonElement | null if (submitButton?.disabled) return
textarea?.form?.requestSubmit() }
// Remove the last attachment on Backspace when the textarea is empty. if (e.key === "Backspace" && textInput.value === "" && files.value.length > 0) { e.preventDefault() const last = files.value[files.value.length - 1] if (last) removeFile(last.id) }}
function handlePaste(e: ClipboardEvent) { const items = e.clipboardData?.items if (!items) return
const pasted: File[] = [] for (const item of Array.from(items)) { if (item.kind === "file") { const file = item.getAsFile() if (file) pasted.push(file) } }
if (pasted.length > 0) { e.preventDefault() addFiles(pasted) }}
const modelValue = computed({ get: () => textInput.value, set: val => setTextInput(val),})</script>
<template> <InputGroupTextarea v-model="modelValue" name="message" :placeholder="props.placeholder" :class="cn('field-sizing-content max-h-48 min-h-14 px-3.5 pt-3 pb-1.5 text-sm leading-normal', props.class)" @keydown="handleKeyDown" @paste="handlePaste" @compositionstart="isComposing = true" @compositionend="isComposing = false" /></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div :class="cn('flex min-w-0 items-center gap-1', props.class)"> <slot /> </div></template>import type { SourceDocumentUIPart } from "ai";import { nanoid } from "nanoid";import { inject, onBeforeUnmount, provide, reactive, ref } from "vue";import type { AttachmentFile, PromptInputContext, PromptInputOptions, ReferencedSource,} from "./types";import { PROMPT_INPUT_KEY } from "./types";
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => { try { const response = await fetch(url); const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(blob); }); } catch { return null; }};
const matchesAccept = (file: File, accept?: string) => { if (!accept || accept.trim() === "") return true; const patterns = accept .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); const fileName = file.name.toLowerCase(); const fileType = file.type.toLowerCase(); return patterns.some((pattern) => { if (pattern.startsWith(".")) return fileName.endsWith(pattern); if (pattern.endsWith("/*")) return fileType.startsWith(pattern.slice(0, -1)); return fileType === pattern; });};
/** Creates the composer state. Used by `PromptInput` (local) and `PromptInputProvider` (lifted). */export function createPromptInputState( initialInput = "", initialOptions: PromptInputOptions = {},): PromptInputContext { const textInput = ref(initialInput); const files = ref<AttachmentFile[]>([]); const referencedSources = ref<ReferencedSource[]>([]); const fileInputRef = ref<HTMLInputElement | null>(null); const isLoading = ref(false); const options = reactive<PromptInputOptions>({ ...initialOptions });
const revoke = (file: AttachmentFile) => { if (file.url?.startsWith("blob:")) URL.revokeObjectURL(file.url); };
onBeforeUnmount(() => { for (const f of files.value) revoke(f); });
const addFiles = (incoming: File[] | FileList) => { const list = Array.from(incoming); const accepted = list.filter((f) => matchesAccept(f, options.accept)); if (list.length && accepted.length === 0) { options.onError?.({ code: "accept", message: "No files match the accepted types.", }); return; } const { maxFileSize, maxFiles } = options; const sized = accepted.filter((f) => maxFileSize ? f.size <= maxFileSize : true, ); if (accepted.length > 0 && sized.length === 0) { options.onError?.({ code: "max_file_size", message: "All files exceed the maximum size.", }); return; } const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - files.value.length) : undefined; const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized; if (typeof capacity === "number" && sized.length > capacity) { options.onError?.({ code: "max_files", message: "Too many files. Some were not added.", }); } files.value = [ ...files.value, ...capped.map( (file): AttachmentFile => ({ filename: file.name, id: nanoid(), mediaType: file.type, size: file.size, type: "file", url: URL.createObjectURL(file), }), ), ]; };
const removeFile = (id: string) => { const found = files.value.find((f) => f.id === id); if (found) revoke(found); files.value = files.value.filter((f) => f.id !== id); };
const clearFiles = () => { for (const f of files.value) revoke(f); files.value = []; };
const addSource = ( incoming: SourceDocumentUIPart | SourceDocumentUIPart[], ) => { const array = Array.isArray(incoming) ? incoming : [incoming]; referencedSources.value = [ ...referencedSources.value, ...array.map((s) => ({ ...s, id: nanoid() })), ]; };
const removeSource = (id: string) => { referencedSources.value = referencedSources.value.filter( (s) => s.id !== id, ); };
const clearSources = () => { referencedSources.value = []; };
const setTextInput = (val: string) => { textInput.value = val; }; const clearInput = () => { textInput.value = ""; }; const openFileDialog = () => { fileInputRef.value?.click(); };
const submitForm = async () => { if (!options.onSubmit) return;
const submittedText = textInput.value; const submittedFiles = [...files.value]; const submittedIds = new Set(submittedFiles.map((f) => f.id)); clearInput();
try { isLoading.value = true; // Blob URLs do not survive the request: convert to data URLs. const converted = await Promise.all( submittedFiles.map(async ({ id: _id, size: _size, ...item }) => { if (item.url?.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); return { ...item, url: dataUrl ?? item.url }; } return item; }), ); await options.onSubmit({ files: converted, text: submittedText }); // Clear only what was submitted (the user may have added more meanwhile). for (const f of files.value) if (submittedIds.has(f.id)) revoke(f); files.value = files.value.filter((f) => !submittedIds.has(f.id)); clearSources(); } catch (e) { // Keep everything so the user can retry. if (textInput.value === "") setTextInput(submittedText); options.onError?.({ code: "submit_error", message: e instanceof Error ? e.message : String(e), }); } finally { isLoading.value = false; } };
return { addFiles, addSource, clearFiles, clearInput, clearSources, fileInputRef, files, isLoading, openFileDialog, options, referencedSources, removeFile, removeSource, setTextInput, submitForm, textInput, };}
/** Provides the state to descendants (called by `PromptInput` / `PromptInputProvider`). */export function providePromptInput(state: PromptInputContext) { provide(PROMPT_INPUT_KEY, state); return state;}
/** Full composer state (text, files, sources, submit). Must be inside `PromptInput` or `PromptInputProvider`. */export function usePromptInput(): PromptInputContext { const context = inject(PROMPT_INPUT_KEY, null); if (!context) { throw new Error( "usePromptInput must be used within a PromptInput or PromptInputProvider", ); } return context;}
/** Attachments slice of the composer state (same shape as the React hook). */export function usePromptInputAttachments() { const { files, addFiles, removeFile, clearFiles, openFileDialog, fileInputRef, } = usePromptInput(); return { add: addFiles, clear: clearFiles, fileInputRef, files, openFileDialog, remove: removeFile, };}
/** Referenced sources slice of the composer state. */export function usePromptInputReferencedSources() { const { referencedSources, addSource, removeSource, clearSources } = usePromptInput(); return { add: addSource, clear: clearSources, remove: removeSource, sources: referencedSources, };}export const captureScreenshot = async (): Promise<File | null> => { if ( typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia ) { return null; }
let stream: MediaStream | null = null; const video = document.createElement("video"); video.muted = true; video.playsInline = true;
try { stream = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true, });
video.srcObject = stream;
await new Promise<void>((resolve, reject) => { video.onloadedmetadata = () => resolve(); video.onerror = () => reject(new Error("Failed to load screen stream")); });
await video.play();
const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { return null; }
const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) { return null; }
context.drawImage(video, 0, 0, width, height); const blob = await new Promise<Blob | null>((resolve) => { canvas.toBlob(resolve, "image/png"); }); if (!blob) { return null; }
const timestamp = new Date() .toISOString() .replaceAll(/[:.]/g, "-") .replace("T", "_") .replace("Z", "");
return new File([blob], `screenshot-${timestamp}.png`, { lastModified: Date.now(), type: "image/png", }); } finally { if (stream) { for (const track of stream.getTracks()) { track.stop(); } } video.pause(); video.srcObject = null; }};import type { FileUIPart, SourceDocumentUIPart } from "ai";import type { InjectionKey, Ref } from "vue";
export interface PromptInputMessage { text: string; files: FileUIPart[];}
export interface AttachmentFile extends FileUIPart { id: string; /** Bytes (only known for files added through the composer). */ size?: number;}
export interface ReferencedSource extends SourceDocumentUIPart { id: string;}
export interface PromptInputError { code: "max_files" | "max_file_size" | "accept" | "submit_error"; message: string;}
/** Validation options; `PromptInput` writes its props here so a lifted provider validates too. */export interface PromptInputOptions { accept?: string; maxFiles?: number; maxFileSize?: number; onError?: (err: PromptInputError) => void; onSubmit?: (message: PromptInputMessage) => void | Promise<void>;}
export interface PromptInputContext { textInput: Ref<string>; files: Ref<AttachmentFile[]>; referencedSources: Ref<ReferencedSource[]>; isLoading: Ref<boolean>; fileInputRef: Ref<HTMLInputElement | null>; options: PromptInputOptions; setTextInput: (val: string) => void; addFiles: (files: File[] | FileList) => void; removeFile: (id: string) => void; clearFiles: () => void; clearInput: () => void; addSource: (sources: SourceDocumentUIPart | SourceDocumentUIPart[]) => void; removeSource: (id: string) => void; clearSources: () => void; openFileDialog: () => void; submitForm: () => Promise<void>;}
export const PROMPT_INPUT_KEY: InjectionKey<PromptInputContext> = Symbol("PromptInputContext");export * from "./context";export { default as PromptInput } from "./PromptInput.vue";export { default as PromptInputActionAddAttachments } from "./PromptInputActionAddAttachments.vue";export { default as PromptInputActionAddScreenshot } from "./PromptInputActionAddScreenshot.vue";export { default as PromptInputActionMenu } from "./PromptInputActionMenu.vue";export { default as PromptInputActionMenuContent } from "./PromptInputActionMenuContent.vue";export { default as PromptInputActionMenuItem } from "./PromptInputActionMenuItem.vue";export { default as PromptInputActionMenuTrigger } from "./PromptInputActionMenuTrigger.vue";export { default as PromptInputBody } from "./PromptInputBody.vue";export { default as PromptInputButton } from "./PromptInputButton.vue";export { default as PromptInputCommand } from "./PromptInputCommand.vue";export { default as PromptInputCommandEmpty } from "./PromptInputCommandEmpty.vue";export { default as PromptInputCommandGroup } from "./PromptInputCommandGroup.vue";export { default as PromptInputCommandInput } from "./PromptInputCommandInput.vue";export { default as PromptInputCommandItem } from "./PromptInputCommandItem.vue";export { default as PromptInputCommandList } from "./PromptInputCommandList.vue";export { default as PromptInputCommandSeparator } from "./PromptInputCommandSeparator.vue";export { default as PromptInputFooter } from "./PromptInputFooter.vue";export { default as PromptInputHeader } from "./PromptInputHeader.vue";export { default as PromptInputHoverCard } from "./PromptInputHoverCard.vue";export { default as PromptInputHoverCardContent } from "./PromptInputHoverCardContent.vue";export { default as PromptInputHoverCardTrigger } from "./PromptInputHoverCardTrigger.vue";export { default as PromptInputProvider } from "./PromptInputProvider.vue";export { default as PromptInputSelect } from "./PromptInputSelect.vue";export { default as PromptInputSelectContent } from "./PromptInputSelectContent.vue";export { default as PromptInputSelectItem } from "./PromptInputSelectItem.vue";export { default as PromptInputSelectTrigger } from "./PromptInputSelectTrigger.vue";export { default as PromptInputSelectValue } from "./PromptInputSelectValue.vue";export { default as PromptInputSubmit } from "./PromptInputSubmit.vue";export { default as PromptInputTab } from "./PromptInputTab.vue";export { default as PromptInputTabBody } from "./PromptInputTabBody.vue";export { default as PromptInputTabItem } from "./PromptInputTabItem.vue";export { default as PromptInputTabLabel } from "./PromptInputTabLabel.vue";export { default as PromptInputTabsList } from "./PromptInputTabsList.vue";export { default as PromptInputTextarea } from "./PromptInputTextarea.vue";export { default as PromptInputTools } from "./PromptInputTools.vue";export * from "./types";import Root from "./prompt-input.svelte";import ActionAddAttachments from "./prompt-input-action-add-attachments.svelte";import ActionAddScreenshot from "./prompt-input-action-add-screenshot.svelte";import ActionMenu from "./prompt-input-action-menu.svelte";import ActionMenuContent from "./prompt-input-action-menu-content.svelte";import ActionMenuItem from "./prompt-input-action-menu-item.svelte";import ActionMenuTrigger from "./prompt-input-action-menu-trigger.svelte";import Body from "./prompt-input-body.svelte";import Button from "./prompt-input-button.svelte";import Command from "./prompt-input-command.svelte";import CommandEmpty from "./prompt-input-command-empty.svelte";import CommandGroup from "./prompt-input-command-group.svelte";import CommandInput from "./prompt-input-command-input.svelte";import CommandItem from "./prompt-input-command-item.svelte";import CommandList from "./prompt-input-command-list.svelte";import CommandSeparator from "./prompt-input-command-separator.svelte";import Footer from "./prompt-input-footer.svelte";import Header from "./prompt-input-header.svelte";import HoverCard from "./prompt-input-hover-card.svelte";import HoverCardContent from "./prompt-input-hover-card-content.svelte";import HoverCardTrigger from "./prompt-input-hover-card-trigger.svelte";import Provider from "./prompt-input-provider.svelte";import Select from "./prompt-input-select.svelte";import SelectContent from "./prompt-input-select-content.svelte";import SelectItem from "./prompt-input-select-item.svelte";import SelectTrigger from "./prompt-input-select-trigger.svelte";import SelectValue from "./prompt-input-select-value.svelte";import Submit from "./prompt-input-submit.svelte";import Tab from "./prompt-input-tab.svelte";import TabBody from "./prompt-input-tab-body.svelte";import TabItem from "./prompt-input-tab-item.svelte";import TabLabel from "./prompt-input-tab-label.svelte";import TabsList from "./prompt-input-tabs-list.svelte";import Textarea from "./prompt-input-textarea.svelte";import Tools from "./prompt-input-tools.svelte";
export * from "./types.js";export * from "./use-prompt-input.svelte.js";export { ActionAddAttachments, ActionAddAttachments as PromptInputActionAddAttachments, ActionAddScreenshot, ActionAddScreenshot as PromptInputActionAddScreenshot, ActionMenu, ActionMenu as PromptInputActionMenu, ActionMenuContent, ActionMenuContent as PromptInputActionMenuContent, ActionMenuItem, ActionMenuItem as PromptInputActionMenuItem, ActionMenuTrigger, ActionMenuTrigger as PromptInputActionMenuTrigger, Body, Body as PromptInputBody, Button, Button as PromptInputButton, Command, Command as PromptInputCommand, CommandEmpty, CommandEmpty as PromptInputCommandEmpty, CommandGroup, CommandGroup as PromptInputCommandGroup, CommandInput, CommandInput as PromptInputCommandInput, CommandItem, CommandItem as PromptInputCommandItem, CommandList, CommandList as PromptInputCommandList, CommandSeparator, CommandSeparator as PromptInputCommandSeparator, Footer, Footer as PromptInputFooter, Header, Header as PromptInputHeader, HoverCard, HoverCard as PromptInputHoverCard, HoverCardContent, HoverCardContent as PromptInputHoverCardContent, HoverCardTrigger, HoverCardTrigger as PromptInputHoverCardTrigger, Provider, Provider as PromptInputProvider, Root, // Root as PromptInput, Select, Select as PromptInputSelect, SelectContent, SelectContent as PromptInputSelectContent, SelectItem, SelectItem as PromptInputSelectItem, SelectTrigger, SelectTrigger as PromptInputSelectTrigger, SelectValue, SelectValue as PromptInputSelectValue, Submit, Submit as PromptInputSubmit, Tab, Tab as PromptInputTab, TabBody, TabBody as PromptInputTabBody, TabItem, TabItem as PromptInputTabItem, TabLabel, TabLabel as PromptInputTabLabel, TabsList, TabsList as PromptInputTabsList, Textarea, Textarea as PromptInputTextarea, Tools, Tools as PromptInputTools,};<script lang="ts"> import ImageIcon from "phosphor-svelte/lib/Image"; import { DropdownMenuItem } from "$lib/registry/ui/dropdown-menu/index.js"; import type { ComponentProps } from "svelte"; import { usePromptInputAttachments } from "./use-prompt-input.svelte.js";
let { label = "Add photos or files", ...restProps }: Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { label?: string } = $props();
const attachments = usePromptInputAttachments();</script>
<DropdownMenuItem {...restProps} onSelect={() => attachments.openFileDialog()}> <ImageIcon class="size-4" /> {label}</DropdownMenuItem><script lang="ts"> import MonitorIcon from "phosphor-svelte/lib/Monitor"; import { DropdownMenuItem } from "$lib/registry/ui/dropdown-menu/index.js"; import type { ComponentProps } from "svelte"; import { captureScreenshot } from "./screenshot.js"; import { usePromptInputAttachments } from "./use-prompt-input.svelte.js";
let { label = "Take screenshot", ...restProps }: Omit<ComponentProps<typeof DropdownMenuItem>, "children"> & { label?: string } = $props();
const attachments = usePromptInputAttachments();
async function handleSelect() { try { const screenshot = await captureScreenshot(); if (screenshot) attachments.add([screenshot]); } catch (error) { // The user dismissed the capture picker: not an error. if ( error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "AbortError") ) { return; } throw error; } }</script>
<DropdownMenuItem {...restProps} onSelect={handleSelect}> <MonitorIcon class="size-4" /> {label}</DropdownMenuItem><script lang="ts"> import { DropdownMenuContent } from "$lib/registry/ui/dropdown-menu/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), align = "start", ...restProps }: ComponentProps<typeof DropdownMenuContent> = $props();</script>
<DropdownMenuContent bind:ref {align} {...restProps} /><script lang="ts"> import { DropdownMenuItem } from "$lib/registry/ui/dropdown-menu/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof DropdownMenuItem> = $props();</script>
<DropdownMenuItem bind:ref {...restProps} /><script lang="ts"> import PlusIcon from "phosphor-svelte/lib/Plus"; import { DropdownMenuTrigger } from "$lib/registry/ui/dropdown-menu/index.js"; import type { ComponentProps } from "svelte"; import PromptInputButton from "./prompt-input-button.svelte";
let { class: className, children, ...restProps }: Omit<ComponentProps<typeof PromptInputButton>, "tooltip"> = $props();</script>
<DropdownMenuTrigger> {#snippet child({ props })} <PromptInputButton {...props} class={className} {...restProps}> {#if children} {@render children()} {:else} <PlusIcon class="size-4" /> {/if} </PromptInputButton> {/snippet}</DropdownMenuTrigger><script lang="ts"> import { DropdownMenu } from "$lib/registry/ui/dropdown-menu/index.js"; import type { ComponentProps } from "svelte";
let { open = $bindable(false), ...restProps }: ComponentProps<typeof DropdownMenu> = $props();</script>
<DropdownMenu bind:open {...restProps} /><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("contents", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { InputGroupButton } from "$lib/registry/ui/input-group/index.js"; import * as Tooltip from "$lib/registry/ui/tooltip/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
type ButtonSize = ComponentProps<typeof InputGroupButton>["size"];
let { ref = $bindable(null), variant = "ghost", size, class: className, tooltip, shortcut, side = "top", children, ...restProps }: Omit<ComponentProps<typeof InputGroupButton>, "size" | "href"> & { size?: ButtonSize; /** Tooltip text; `shortcut` and `side` refine it. */ tooltip?: string; shortcut?: string; side?: "top" | "right" | "bottom" | "left"; } = $props();
// An icon alone is a square button; an icon plus a label is a small text button. let childCount = $state(1); $effect(() => { const el = ref; if (!el) return; const sync = () => { childCount = Array.from(el.childNodes).filter( (node) => node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) ).length; }; sync(); const observer = new MutationObserver(sync); observer.observe(el, { childList: true, characterData: true, subtree: false }); return () => observer.disconnect(); }); const resolvedSize = $derived<ButtonSize>(size ?? (childCount > 1 ? "sm" : "icon-sm"));</script>
{#snippet button(props: Record<string, unknown>)} <InputGroupButton bind:ref type="button" {variant} size={resolvedSize} class={cn("text-[13px] text-foreground", resolvedSize === "sm" && "h-8 gap-1.5 px-2.5", className)} {...props} {...restProps} > {@render children?.()} </InputGroupButton>{/snippet}
{#if tooltip} <Tooltip.Provider> <Tooltip.Root> <Tooltip.Trigger> {#snippet child({ props })} {@render button(props)} {/snippet} </Tooltip.Trigger> <Tooltip.Content {side}> {tooltip} {#if shortcut} <span class="ml-2 text-muted-foreground">{shortcut}</span> {/if} </Tooltip.Content> </Tooltip.Root> </Tooltip.Provider>{:else} {@render button({})}{/if}<script lang="ts"> import { CommandEmpty } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof CommandEmpty> = $props();</script>
<CommandEmpty bind:ref {...restProps}> {@render children?.()}</CommandEmpty><script lang="ts"> import { CommandGroup } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof CommandGroup> = $props();</script>
<CommandGroup bind:ref {...restProps}> {@render children?.()}</CommandGroup><script lang="ts"> import { CommandInput } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof CommandInput> = $props();</script>
<CommandInput bind:ref {...restProps}> {@render children?.()}</CommandInput><script lang="ts"> import { CommandItem } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof CommandItem> = $props();</script>
<CommandItem bind:ref {...restProps}> {@render children?.()}</CommandItem><script lang="ts"> import { CommandList } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof CommandList> = $props();</script>
<CommandList bind:ref {...restProps}> {@render children?.()}</CommandList><script lang="ts"> import { CommandSeparator } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof CommandSeparator> = $props();</script>
<CommandSeparator bind:ref {...restProps}> {@render children?.()}</CommandSeparator><script lang="ts"> import { Command } from "$lib/registry/ui/command/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof Command> = $props();</script>
<Command bind:ref {...restProps}> {@render children?.()}</Command><script lang="ts"> import { InputGroupAddon } from "$lib/registry/ui/input-group/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { class: className, children, ...restProps }: Omit<ComponentProps<typeof InputGroupAddon>, "align"> = $props();</script>
<InputGroupAddon align="block-end" class={cn("justify-between gap-1 px-2 pt-1.5 pb-2", className)} {...restProps}> {@render children?.()}</InputGroupAddon><script lang="ts"> import { InputGroupAddon } from "$lib/registry/ui/input-group/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { class: className, children, ...restProps }: Omit<ComponentProps<typeof InputGroupAddon>, "align"> = $props();</script>
<InputGroupAddon align="block-start" class={cn("flex-wrap gap-1.5 px-3 pt-2.5 pb-0 has-[>:empty]:hidden", className)} {...restProps}> {@render children?.()}</InputGroupAddon><script lang="ts"> import { HoverCardContent } from "$lib/registry/ui/hover-card/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), align = "start", ...restProps }: ComponentProps<typeof HoverCardContent> = $props();</script>
<HoverCardContent bind:ref {align} {...restProps} /><script lang="ts"> import { HoverCardTrigger } from "$lib/registry/ui/hover-card/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof HoverCardTrigger> = $props();</script>
<HoverCardTrigger bind:ref {...restProps}> {@render children?.()}</HoverCardTrigger><script lang="ts"> import { HoverCard } from "$lib/registry/ui/hover-card/index.js"; import type { ComponentProps } from "svelte";
let { open = $bindable(false), openDelay = 0, closeDelay = 0, ...restProps }: ComponentProps<typeof HoverCard> = $props();</script>
<HoverCard bind:open {openDelay} {closeDelay} {...restProps} /><script lang="ts"> import type { Snippet } from "svelte"; import { PromptInputController, setPromptInput } from "./use-prompt-input.svelte.js";
// Optional global provider that lifts PromptInput state outside of PromptInput. // If you don't use it, PromptInput stays fully self-managed. let { initialInput = "", children }: { initialInput?: string; children?: Snippet } = $props();
// svelte-ignore state_referenced_locally setPromptInput(new PromptInputController(initialInput));</script>
{@render children?.()}<script lang="ts"> import { SelectContent } from "$lib/registry/ui/select/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), children, ...restProps }: ComponentProps<typeof SelectContent> = $props();</script>
<SelectContent bind:ref {...restProps}> {@render children?.()}</SelectContent><script lang="ts"> import { SelectItem } from "$lib/registry/ui/select/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof SelectItem> = $props();</script>
<SelectItem bind:ref {...restProps} /><script lang="ts"> import { SelectTrigger } from "$lib/registry/ui/select/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), class: className, children, ...restProps }: ComponentProps<typeof SelectTrigger> = $props();</script>
<SelectTrigger bind:ref class={cn( "h-8 border-none bg-transparent px-2.5 text-[13px] font-medium text-foreground shadow-none transition-colors data-[size=default]:h-8", "hover:bg-accent data-[state=open]:bg-accent data-[state=open]:shadow-none", className )} {...restProps}> {@render children?.()}</SelectTrigger><script lang="ts"> import { SelectValue } from "$lib/registry/ui/select/index.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof SelectValue> = $props();</script>
<SelectValue bind:ref {...restProps} /><script lang="ts"> import { Select } from "$lib/registry/ui/select/index.js"; import type { ComponentProps } from "svelte";
// Pass `type="single"` (or `"multiple"`) like the underlying Bits UI Select. let { open = $bindable(false), value = $bindable(), ...restProps }: ComponentProps<typeof Select> = $props();</script>
<Select bind:open bind:value={value as never} {...restProps} /><script lang="ts"> import StopIcon from "phosphor-svelte/lib/Stop"; import XIcon from "phosphor-svelte/lib/X"; import ArrowUpIcon from "phosphor-svelte/lib/ArrowUp"; import { InputGroupButton } from "$lib/registry/ui/input-group/index.js"; import { Spinner } from "$lib/registry/ui/spinner/index.js"; import { cn } from "$lib/utils.js"; import type { ChatStatus } from "ai"; import type { ComponentProps } from "svelte";
let { class: className, variant, size = "icon-sm", status, onStop, onclick, children, ...restProps }: Omit<ComponentProps<typeof InputGroupButton>, "href" | "onclick"> & { status?: ChatStatus; /** With `onStop` the button turns into a stop button while generating. */ onStop?: () => void; onclick?: (event: MouseEvent) => void; } = $props();
const isGenerating = $derived(status === "submitted" || status === "streaming"); const isStop = $derived(isGenerating && !!onStop);
function handleClick(event: MouseEvent) { if (isStop) { event.preventDefault(); onStop?.(); return; } onclick?.(event); }</script>
<InputGroupButton data-slot="ai-prompt-input-submit" data-status={status} aria-label={isGenerating ? "Stop" : "Submit"} type={isStop ? "button" : "submit"} {size} variant={variant ?? (status === "error" ? "destructive" : "default")} class={cn("rounded-[9px]", className)} onclick={handleClick} {...restProps}> {#if children} {@render children()} {:else if status === "submitted"} <Spinner /> {:else if status === "streaming"} <StopIcon class="size-3.5 fill-current" /> {:else if status === "error"} <XIcon class="size-4" /> {:else} <ArrowUpIcon class="size-4" /> {/if}</InputGroupButton><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("space-y-1", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLHeadingElement> & { children?: Snippet } = $props();</script>
<h3 class={cn("mb-2 px-3 text-xs font-medium text-muted-foreground", className)} {...restProps}> {@render children?.()}</h3><script lang="ts"> import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={className} {...restProps}> {@render children?.()}</div><script lang="ts"> import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={className} {...restProps}> {@render children?.()}</div><script lang="ts"> import { InputGroupTextarea } from "$lib/registry/ui/input-group/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte"; import { usePromptInput } from "./use-prompt-input.svelte.js";
let { ref = $bindable(null), class: className, placeholder = "What would you like to know?", onkeydown, onpaste, ...restProps }: Omit<ComponentProps<typeof InputGroupTextarea>, "value"> = $props();
const controller = usePromptInput(); let isComposing = $state(false);
function handleKeyDown(event: KeyboardEvent & { currentTarget: HTMLTextAreaElement }) { (onkeydown as ((e: KeyboardEvent) => void) | undefined)?.(event); if (event.defaultPrevented) return;
if (event.key === "Enter") { if (isComposing || event.isComposing || event.shiftKey) return; event.preventDefault();
const form = event.currentTarget.form; const submitButton = form?.querySelector( 'button[type="submit"]' ) as HTMLButtonElement | null; if (submitButton?.disabled) return; form?.requestSubmit(); }
// Remove the last attachment on Backspace when the textarea is empty. if (event.key === "Backspace" && controller.textInput === "" && controller.files.length > 0) { event.preventDefault(); const last = controller.files.at(-1); if (last) controller.removeFile(last.id); } }
function handlePaste(event: ClipboardEvent & { currentTarget: HTMLTextAreaElement }) { (onpaste as ((e: ClipboardEvent) => void) | undefined)?.(event); const items = event.clipboardData?.items; if (!items) return;
const pasted: File[] = []; for (const item of Array.from(items)) { if (item.kind === "file") { const file = item.getAsFile(); if (file) pasted.push(file); } } if (pasted.length > 0) { event.preventDefault(); controller.addFiles(pasted); } }</script>
<InputGroupTextarea bind:ref bind:value={controller.textInput} name="message" {placeholder} class={cn("field-sizing-content max-h-48 min-h-14 px-3.5 pt-3 pb-1.5 text-sm leading-normal", className)} oncompositionstart={() => (isComposing = true)} oncompositionend={() => (isComposing = false)} onkeydown={handleKeyDown} onpaste={handlePaste} {...restProps}/><script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div class={cn("flex min-w-0 items-center gap-1", className)} {...restProps}> {@render children?.()}</div><script lang="ts"> import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js"; import { InputGroup } from "$lib/registry/ui/input-group/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLFormAttributes } from "svelte/elements"; import type { PromptInputError, PromptInputMessage } from "./types.js"; import { getPromptInput, PromptInputController, setPromptInput, } from "./use-prompt-input.svelte.js";
let { class: className, accept, multiple, globalDrop = false, maxFiles, maxFileSize, initialInput = "", elevation = "auto", onSubmit, onError, children, ...restProps }: Omit<HTMLFormAttributes, "onsubmit" | "onerror" | "children"> & { /** e.g. `image/*`, `.csv`; leave undefined for any. */ accept?: string; multiple?: boolean; /** When true, accepts drops anywhere on the document. Default false (opt-in). */ globalDrop?: boolean; maxFiles?: number; /** Bytes. */ maxFileSize?: number; initialInput?: string; /** ✦ depth of the composer plate (overlay role: floating in layered mode). The submit button follows the Button rules. */ elevation?: Elevation; /** May be async: the composer clears on success and keeps the text if it throws. */ onSubmit: (message: PromptInputMessage) => void | Promise<void>; onError?: (err: PromptInputError) => void; /** Receives the composer state (files, remove, ...) so children can render attachments. */ children?: Snippet< [ { files: PromptInputController["files"]; remove: PromptInputController["removeFile"]; add: PromptInputController["addFiles"]; clear: PromptInputController["clearFiles"]; openFileDialog: PromptInputController["openFileDialog"]; textInput: string; }, ] >; } = $props();
const level = useElevation(() => elevation, "overlay");
// Inside a PromptInputProvider the state is lifted; otherwise it is local to this composer. // svelte-ignore state_referenced_locally const controller = getPromptInput() ?? setPromptInput(new PromptInputController(initialInput));
$effect(() => { controller.options.accept = accept; controller.options.maxFiles = maxFiles; controller.options.maxFileSize = maxFileSize; controller.options.onSubmit = onSubmit; controller.options.onError = onError; });
$effect(() => () => controller.destroy());
let fileInput = $state<HTMLInputElement | null>(null); $effect(() => { controller.fileInput = fileInput; });
function handleDragOver(e: DragEvent) { if (e.dataTransfer?.types?.includes("Files")) e.preventDefault(); }
function handleDrop(e: DragEvent) { if (e.dataTransfer?.types?.includes("Files")) e.preventDefault(); if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { controller.addFiles(e.dataTransfer.files); } }
$effect(() => { if (!globalDrop) return; document.addEventListener("dragover", handleDragOver); document.addEventListener("drop", handleDrop); return () => { document.removeEventListener("dragover", handleDragOver); document.removeEventListener("drop", handleDrop); }; });
function onFileChange(event: Event) { const input = event.currentTarget as HTMLInputElement; if (input.files) controller.addFiles(input.files); // Reset so a previously removed file can be picked again. input.value = ""; }</script>
<input bind:this={fileInput} type="file" class="hidden" aria-label="Upload files" title="Upload files" {accept} {multiple} onchange={onFileChange}/><form data-slot="ai-prompt-input" class={cn("w-full", className)} onsubmit={(event) => { event.preventDefault(); controller.submit(); }} ondragover={globalDrop ? undefined : handleDragOver} ondrop={globalDrop ? undefined : handleDrop} {...restProps}> <InputGroup elevation={level.current} class="h-auto flex-col overflow-hidden rounded-[calc(var(--radius)*1.6)]" > {@render children?.({ files: controller.files, remove: controller.removeFile, add: controller.addFiles, clear: controller.clearFiles, openFileDialog: controller.openFileDialog, textInput: controller.textInput, })} </InputGroup></form>export const captureScreenshot = async (): Promise<File | null> => { if ( typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia ) { return null; }
let stream: MediaStream | null = null; const video = document.createElement("video"); video.muted = true; video.playsInline = true;
try { stream = await navigator.mediaDevices.getDisplayMedia({ audio: false, video: true, });
video.srcObject = stream;
await new Promise<void>((resolve, reject) => { video.onloadedmetadata = () => resolve(); video.onerror = () => reject(new Error("Failed to load screen stream")); });
await video.play();
const width = video.videoWidth; const height = video.videoHeight; if (!width || !height) { return null; }
const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) { return null; }
context.drawImage(video, 0, 0, width, height); const blob = await new Promise<Blob | null>((resolve) => { canvas.toBlob(resolve, "image/png"); }); if (!blob) { return null; }
const timestamp = new Date() .toISOString() .replaceAll(/[:.]/g, "-") .replace("T", "_") .replace("Z", "");
return new File([blob], `screenshot-${timestamp}.png`, { lastModified: Date.now(), type: "image/png", }); } finally { if (stream) { for (const track of stream.getTracks()) { track.stop(); } } video.pause(); video.srcObject = null; }};import type { FileUIPart, SourceDocumentUIPart } from "ai";
export interface PromptInputMessage { text: string; files: FileUIPart[];}
export interface AttachmentFile extends FileUIPart { id: string; /** Bytes (only known for files added through the composer). */ size?: number;}
export interface ReferencedSource extends SourceDocumentUIPart { id: string;}
export interface PromptInputError { code: "max_files" | "max_file_size" | "accept" | "submit_error"; message: string;}
/** Validation options; `PromptInput` writes its props here so a lifted provider validates too. */export interface PromptInputOptions { accept?: string; maxFiles?: number; maxFileSize?: number; onError?: (err: PromptInputError) => void; onSubmit?: (message: PromptInputMessage) => void | Promise<void>;}import type { SourceDocumentUIPart } from "ai";import { nanoid } from "nanoid";import { getContext, setContext } from "svelte";import type { AttachmentFile, PromptInputOptions, ReferencedSource,} from "./types.js";
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => { try { const response = await fetch(url); const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(blob); }); } catch { return null; }};
const matchesAccept = (file: File, accept?: string) => { if (!accept || accept.trim() === "") return true; const patterns = accept .split(",") .map((s) => s.trim().toLowerCase()) .filter(Boolean); const fileName = file.name.toLowerCase(); const fileType = file.type.toLowerCase(); return patterns.some((pattern) => { if (pattern.startsWith(".")) return fileName.endsWith(pattern); if (pattern.endsWith("/*")) return fileType.startsWith(pattern.slice(0, -1)); return fileType === pattern; });};
const revoke = (file: AttachmentFile) => { if (file.url?.startsWith("blob:")) URL.revokeObjectURL(file.url);};
/** Composer state. Created by `PromptInput` (local) or `PromptInputProvider` (lifted). */export class PromptInputController { textInput = $state(""); files = $state<AttachmentFile[]>([]); referencedSources = $state<ReferencedSource[]>([]); isLoading = $state(false); fileInput: HTMLInputElement | null = null; options: PromptInputOptions = {};
constructor(initialInput = "") { this.textInput = initialInput; }
setTextInput = (value: string) => { this.textInput = value; };
clearInput = () => { this.textInput = ""; };
addFiles = (incoming: File[] | FileList) => { const list = Array.from(incoming); const { accept, maxFileSize, maxFiles, onError } = this.options; const accepted = list.filter((f) => matchesAccept(f, accept)); if (list.length && accepted.length === 0) { onError?.({ code: "accept", message: "No files match the accepted types.", }); return; } const sized = accepted.filter((f) => maxFileSize ? f.size <= maxFileSize : true, ); if (accepted.length > 0 && sized.length === 0) { onError?.({ code: "max_file_size", message: "All files exceed the maximum size.", }); return; } const capacity = typeof maxFiles === "number" ? Math.max(0, maxFiles - this.files.length) : undefined; const capped = typeof capacity === "number" ? sized.slice(0, capacity) : sized; if (typeof capacity === "number" && sized.length > capacity) { onError?.({ code: "max_files", message: "Too many files. Some were not added.", }); } this.files = [ ...this.files, ...capped.map( (file): AttachmentFile => ({ filename: file.name, id: nanoid(), mediaType: file.type, size: file.size, type: "file", url: URL.createObjectURL(file), }), ), ]; };
removeFile = (id: string) => { const found = this.files.find((f) => f.id === id); if (found) revoke(found); this.files = this.files.filter((f) => f.id !== id); };
clearFiles = () => { for (const f of this.files) revoke(f); this.files = []; };
addSource = (incoming: SourceDocumentUIPart | SourceDocumentUIPart[]) => { const array = Array.isArray(incoming) ? incoming : [incoming]; this.referencedSources = [ ...this.referencedSources, ...array.map((s) => ({ ...s, id: nanoid() })), ]; };
removeSource = (id: string) => { this.referencedSources = this.referencedSources.filter((s) => s.id !== id); };
clearSources = () => { this.referencedSources = []; };
openFileDialog = () => { this.fileInput?.click(); };
/** Release blob URLs (called when the owning component is destroyed). */ destroy = () => { for (const f of this.files) revoke(f); };
submit = async () => { const { onSubmit, onError } = this.options; if (!onSubmit) return;
const submittedText = this.textInput; const submitted = [...this.files]; const submittedIds = new Set(submitted.map((f) => f.id)); this.clearInput();
try { this.isLoading = true; // Blob URLs do not survive the request: convert to data URLs. const converted = await Promise.all( submitted.map(async ({ id: _id, size: _size, ...item }) => { if (item.url?.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); return { ...item, url: dataUrl ?? item.url }; } return item; }), ); await onSubmit({ files: converted, text: submittedText }); // Clear only what was submitted (the user may have added more meanwhile). for (const f of this.files) if (submittedIds.has(f.id)) revoke(f); this.files = this.files.filter((f) => !submittedIds.has(f.id)); this.clearSources(); } catch (e) { // Keep everything so the user can retry. if (this.textInput === "") this.setTextInput(submittedText); onError?.({ code: "submit_error", message: e instanceof Error ? e.message : String(e), }); } finally { this.isLoading = false; } };}
const KEY = Symbol("ai-prompt-input");
export function setPromptInput(controller: PromptInputController) { return setContext(KEY, controller);}
/** The lifted or local controller, or undefined outside a composer. */export function getPromptInput(): PromptInputController | undefined { return getContext<PromptInputController | undefined>(KEY);}
/** Full composer state (text, files, sources, submit). Must be inside `PromptInput` or `PromptInputProvider`. */export function usePromptInput(): PromptInputController { const controller = getPromptInput(); if (!controller) { throw new Error( "usePromptInput must be used within a PromptInput or PromptInputProvider", ); } return controller;}
/** Attachments slice of the composer state (same shape as the React hook, live getters). */export function usePromptInputAttachments() { const c = usePromptInput(); return { add: c.addFiles, clear: c.clearFiles, get files() { return c.files; }, openFileDialog: c.openFileDialog, remove: c.removeFile, };}
/** Referenced sources slice of the composer state. */export function usePromptInputReferencedSources() { const c = usePromptInput(); return { add: c.addSource, clear: c.clearSources, remove: c.removeSource, get sources() { return c.referencedSources; }, };}Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
The composer plate is an overlay surface: flat by default, floating (+2) in layered mode. sunken (-1) is a soft inset, raised (+1) a bevel, floating (+2) a bevel with a soft drop. The submit button follows the Button rules.
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools,} from "@/components/ai/prompt-input";
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex w-full max-w-2xl flex-col gap-5"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <PromptInput elevation={value} onSubmit={() => {}} className="max-w-xl" > <PromptInputBody> <PromptInputTextarea defaultValue="Rebalance MAG4" /> </PromptInputBody> <PromptInputFooter> <PromptInputTools /> <PromptInputSubmit status="ready" /> </PromptInputFooter> </PromptInput> </div> ))} </div> );}<script setup lang="ts">import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools,} from "@/components/ai/prompt-input";
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }];</script>
<template> <div class="flex w-full max-w-2xl flex-col gap-5"> <div v-for="level in levels" :key="level.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ level.label }}</p> <PromptInput :elevation="level.value" initial-input="Rebalance MAG4" class="max-w-xl" @submit="() => {}"> <PromptInputBody> <PromptInputTextarea /> </PromptInputBody> <PromptInputFooter> <PromptInputTools /> <PromptInputSubmit status="ready" /> </PromptInputFooter> </PromptInput> </div> </div></template><script lang="ts"> import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea, PromptInputTools, } from "$lib/components/ai/prompt-input";
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }] as const;</script>
<div class="flex w-full max-w-2xl flex-col gap-5"> {#each levels as level (level.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{level.label}</p> <PromptInput elevation={level.value} initialInput="Rebalance MAG4" class="max-w-xl" onSubmit={() => {}}> <PromptInputBody> <PromptInputTextarea /> </PromptInputBody> <PromptInputFooter> <PromptInputTools /> <PromptInputSubmit status="ready" /> </PromptInputFooter> </PromptInput> </div> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
| PromptInput | onSubmit, accept, multiple, maxFiles, maxFileSize, globalDrop, onError |
(message, event) => void, … |
|
| PromptInput | elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
| PromptInputTextarea | InputGroupTextarea props |
Enter submits, Shift+Enter newline | |
| PromptInputSubmit | status, onStop |
ready | submitted | streaming | error |
|
| PromptInputProvider | initialInput |
string |
lifts text and attachments out of the form |
| PromptInputHeader/Body/Footer/Tools/Button/ActionMenu*/Select*/Command*/HoverCard* |
Hooks: usePromptInputAttachments, usePromptInputController, useProviderAttachments, usePromptInputReferencedSources.
Vue and Svelte
Section titled “Vue and Svelte”- Events: Vue
@submit/@error/@stop(onPromptInputSubmit); SvelteonSubmit/onError/onStop.submitmay be async: the composer clears on success and keeps the text if it throws. Without a stop listener the submit button never turns into a stop button. - Initial text:
initialInput(React demos use the textareadefaultValue). - To render the attached files, read the composer state: Vue passes slot props (
v-slot="{ files, remove }"), Svelte passes snippet arguments ({#snippet children({ files, remove })}), React usesusePromptInputAttachments(). The hooksusePromptInput,usePromptInputAttachmentsandusePromptInputReferencedSourcesexist in all three ports (inside the composer or aPromptInputProvider). - Vue and Svelte
PromptInputButtonpicks the square (icon-sm) or the labelled (sm) size from its content, like React; the tooltip takestooltip,shortcutandsideprops. - Svelte
PromptInputSelectneedstype="single"like the Bits UI Select.
✦ Edmi additions
Section titled “✦ Edmi additions”- The send icon follows the chat status: arrow up (ready), spinner (submitted), stop (streaming, with
onStop), x on destructive (error). elevation✦ sets the depth of the composer plate (overlay role); the submit button keeps its own Buttonelevation.- Focus ring, 16px radius and
--cardfill come from the Edmi tokens.