New: Activity Terrain +12 more

AI Input Selector

AI chat input with a model menu, a separate Effort control, file chips you can drop or paste, a / command menu, and Stop while replying.

1 exampleDepends onlucide-reactmotion@radix-ui/react-dropdown-menuFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/ai-prompt.json
Install manually
  1. Install the dependencies:

    npm install lucide-react motion @radix-ui/react-dropdown-menu
  2. Copy this into components/kokonut/ai-prompt.tsx:

    ai-prompt.tsx
    "use client";
    
    /**
     * @author: @kokonutui
     * @description: AI Prompt Input
     * @version: 2.0.0
     * @date: 2026-10-15
     * @license: MIT
     * @website: https://kokonutui.com
     * @github: https://github.com/kokonut-labs/kokonutui
     */
    
    import {
      AlignLeft,
      ArrowUp,
      Bot,
      Check,
      ChevronDown,
      Code2,
      FileText,
      Languages,
      Lightbulb,
      Plus,
      Slash,
      X,
    } from "lucide-react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { type ReactNode, useEffect, useId, useRef, useState } from "react";
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuLabel,
      DropdownMenuRadioGroup,
      DropdownMenuRadioItem,
      DropdownMenuTrigger,
    } from "@/components/kokonut/ui/dropdown-menu";
    import { Textarea } from "@/components/kokonut/ui/textarea";
    import { useAutoResizeTextarea } from "@/components/kokonut/hooks/use-auto-resize-textarea";
    import { cn } from "@/lib/utils";
    
    export interface PromptCommand {
      id: string;
      label: string;
      description?: string;
      icon?: ReactNode;
    }
    
    export interface PromptAttachment {
      id: string;
      file: File;
    }
    
    export interface PromptModel {
      name: string;
      /** One line under the name in the menu. */
      description?: string;
      /**
       * Effort levels, lowest first, e.g. ["Low", "Medium", "High"]. Leave out
       * for a model without a thinking control: the Effort button hides.
       */
      efforts?: string[];
      /** Effort to start on when the model is picked fresh. */
      defaultEffort?: string;
    }
    
    interface AIPromptProps {
      models?: PromptModel[];
      /** Name of the model selected at first. */
      defaultModel?: string;
      placeholder?: string;
      /** One line above the box, e.g. a promo. Pass "" to hide the header. */
      headerText?: string;
      /** Label of the header button, which switches to `promotedModel`. */
      headerAction?: string;
      /** The model the header talks about; its icon leads the header. */
      promotedModel?: string;
      /**
       * Sends the message. Return a promise to show the Stop button until it
       * settles; `signal` aborts when the user presses Stop. Without it, the
       * component plays a short pretend reply so the demo feels alive.
       */
      onSubmit?: (
        value: string,
        model: string,
        options: {
          /** The effort level, or null for a model without one. */
          effort: string | null;
          files: File[];
          signal: AbortSignal;
          command: PromptCommand | null;
        }
      ) => unknown;
      /** Shown when the message starts with "/". Pass [] to turn the menu off. */
      commands?: PromptCommand[];
      /** Called when the user presses Stop while a reply is in progress. */
      onStop?: () => void;
      /** Called with the files picked, dropped or pasted into the prompt. */
      onAttach?: (files: FileList | File[]) => void;
      className?: string;
    }
    
    const DEFAULT_COMMANDS: PromptCommand[] = [
      {
        id: "summarize",
        label: "Summarize",
        description: "Shorten text or files",
        icon: <AlignLeft className="size-4" />,
      },
      {
        id: "translate",
        label: "Translate",
        description: "Into another language",
        icon: <Languages className="size-4" />,
      },
      {
        id: "explain",
        label: "Explain",
        description: "Step by step, in plain words",
        icon: <Lightbulb className="size-4" />,
      },
      {
        id: "code",
        label: "Write code",
        description: "Functions, tests and fixes",
        icon: <Code2 className="size-4" />,
      },
    ];
    
    const DEFAULT_MODELS: PromptModel[] = [
      {
        name: "Claude Opus 5.5",
        description: "Most capable, for complex work",
        efforts: ["Low", "Medium", "High", "Max"],
        defaultEffort: "High",
      },
      {
        name: "Claude Sonnet 5.5",
        description: "Fast and smart for everyday tasks",
        efforts: ["Low", "Medium", "High", "Max"],
        defaultEffort: "Medium",
      },
      {
        name: "Claude Fable 5.1",
        description: "Newest model, free to try this weekend",
        efforts: ["Low", "Medium", "High"],
        defaultEffort: "Medium",
      },
      {
        name: "Claude Haiku 5.5",
        description: "Fastest, no thinking step",
      },
    ];
    
    /** Hints for the usual effort names; other names show without one. */
    const EFFORT_HINTS: Record<string, string> = {
      Minimal: "Skips reasoning almost entirely",
      Low: "Answers right away",
      Medium: "Thinks briefly first",
      High: "Thinks longer on hard problems",
      Max: "No limit on thinking",
    };
    
    const effortsOf = (model: PromptModel | undefined) => model?.efforts ?? [];
    
    const startEffort = (model: PromptModel | undefined) => {
      const efforts = effortsOf(model);
      if (efforts.length === 0) {
        return null;
      }
      return model?.defaultEffort && efforts.includes(model.defaultEffort)
        ? model.defaultEffort
        : (efforts.at(-1) ?? null);
    };
    
    /**
     * The effort to keep when switching models: the same level if the new model
     * has it, otherwise the one at the same height on its scale.
     */
    function carryEffort(
      from: PromptModel | undefined,
      to: PromptModel,
      effort: string | null
    ) {
      const next = effortsOf(to);
      if (next.length === 0) {
        return null;
      }
      if (effort && next.includes(effort)) {
        return effort;
      }
      const previous = effortsOf(from);
      if (!effort || previous.length < 2) {
        return startEffort(to);
      }
      const share = previous.indexOf(effort) / (previous.length - 1);
      return next[Math.round(share * (next.length - 1))] ?? startEffort(to);
    }
    
    // Decorative: the icon sits next to the model name, which carries the label.
    const CLAUDE_ICON = (
      <svg
        aria-hidden="true"
        fill="#D57156"
        height="1em"
        style={{ flex: "none", lineHeight: "1" }}
        viewBox="0 0 256 257"
        width="1em"
        xmlns="http://www.w3.org/2000/svg"
      >
        <path d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" />
      </svg>
    );
    
    const getModelIcon = (model: string) =>
      model.startsWith("Claude") ? (
        CLAUDE_ICON
      ) : (
        <Bot aria-hidden="true" className="h-4 w-4 opacity-50" />
      );
    
    const EASE_OUT = [0.23, 1, 0.32, 1] as const;
    const DEMO_REPLY_MS = 2400;
    const WHITESPACE = /\s/;
    
    const formatSize = (bytes: number) => {
      if (bytes < 1024) {
        return `${bytes} B`;
      }
      if (bytes < 1024 * 1024) {
        return `${Math.round(bytes / 1024)} KB`;
      }
      return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
    };
    
    const waitFor = (ms: number, signal: AbortSignal) =>
      new Promise<void>((resolve) => {
        const id = setTimeout(resolve, ms);
        signal.addEventListener(
          "abort",
          () => {
            clearTimeout(id);
            resolve();
          },
          { once: true }
        );
      });
    
    function AttachmentChip({
      attachment,
      onRemove,
    }: {
      attachment: PromptAttachment;
      onRemove: () => void;
    }) {
      const { file } = attachment;
      const isImage = file.type.startsWith("image/");
      const [previewUrl, setPreviewUrl] = useState<string | null>(null);
    
      // Object URLs are created after mount and revoked on unmount, so a
      // removed chip doesn't keep its image in memory.
      useEffect(() => {
        if (!isImage) {
          return;
        }
        const url = URL.createObjectURL(file);
        setPreviewUrl(url);
        return () => URL.revokeObjectURL(url);
      }, [file, isImage]);
    
      return (
        <motion.li
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          className="flex h-11 max-w-52 items-center gap-2 rounded-xl bg-black/[0.03] py-1 pr-1.5 pl-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
          exit={{
            opacity: 0,
            filter: "blur(2px)",
            transition: { duration: 0.12, ease: EASE_OUT },
          }}
          initial={{ opacity: 0, y: 4, filter: "blur(2px)" }}
          layout="position"
          transition={{ duration: 0.2, ease: EASE_OUT }}
        >
          <span className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/60">
            {previewUrl ? (
              // biome-ignore lint/performance/noImgElement: object URL preview, nothing to optimise
              <img
                alt=""
                className="size-full object-cover"
                height={36}
                src={previewUrl}
                width={36}
              />
            ) : (
              <FileText aria-hidden="true" className="size-4" />
            )}
          </span>
          <span className="min-w-0 flex-1 leading-tight">
            <span className="block truncate text-black text-xs dark:text-white">
              {file.name}
            </span>
            <span className="block text-[10px] text-black/50 tabular-nums dark:text-white/50">
              {formatSize(file.size)}
            </span>
          </span>
          <button
            aria-label={`Remove ${file.name}`}
            className="flex size-6 shrink-0 items-center justify-center rounded-md text-black/40 transition-colors duration-150 hover:bg-black/5 hover:text-black focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-blue-500 dark:text-white/40 dark:hover:bg-white/10 dark:hover:text-white"
            onClick={onRemove}
            type="button"
          >
            <X aria-hidden="true" className="size-3.5" />
          </button>
        </motion.li>
      );
    }
    
    /** Bolds the part of the label the user has typed after "/". */
    function MatchedLabel({ label, query }: { label: string; query: string }) {
      const start = query ? label.toLowerCase().indexOf(query) : -1;
      if (start < 0) {
        return <>{label}</>;
      }
      const end = start + query.length;
      return (
        <>
          {label.slice(0, start)}
          <span className="font-medium text-black dark:text-white">
            {label.slice(start, end)}
          </span>
          {label.slice(end)}
        </>
      );
    }
    
    function Kbd({ children }: { children: ReactNode }) {
      return (
        <kbd className="inline-flex h-4 min-w-4 items-center justify-center rounded bg-black/5 px-1 font-sans text-[10px] text-black/60 dark:bg-white/10 dark:text-white/60">
          {children}
        </kbd>
      );
    }
    
    function CommandMenu({
      id,
      commands,
      query,
      activeIndex,
      onHover,
      onSelect,
    }: {
      id: string;
      commands: PromptCommand[];
      query: string | null;
      activeIndex: number;
      onHover: (index: number) => void;
      onSelect: (command: PromptCommand) => void;
    }) {
      // Opened by typing, so it only fades: nothing travels between the
      // keystroke and the list.
      return (
        <motion.div
          animate={{ opacity: 1 }}
          className="absolute inset-x-0 bottom-full z-10 mb-2 rounded-xl bg-white p-1 shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] ring-1 ring-black/10 dark:bg-zinc-900 dark:ring-white/10"
          exit={{ opacity: 0, transition: { duration: 0.08 } }}
          initial={{ opacity: 0 }}
          transition={{ duration: 0.12, ease: EASE_OUT }}
        >
          {/* The highlight follows the keyboard instantly, without a transition. */}
          <div aria-label="Commands" id={id} role="listbox">
            {commands.map((command, i) => (
              // biome-ignore lint/a11y/useKeyWithClickEvents: options are driven from the textarea with the arrow keys
              <div
                aria-selected={i === activeIndex}
                className={cn(
                  "flex h-9 cursor-pointer items-center gap-2 rounded-lg px-2",
                  i === activeIndex && "bg-black/5 dark:bg-white/10"
                )}
                id={`${id}-${command.id}`}
                key={command.id}
                onClick={() => onSelect(command)}
                onMouseDown={(e) => e.preventDefault()}
                onPointerMove={() => onHover(i)}
                role="option"
                tabIndex={-1}
              >
                <span className="flex size-6 shrink-0 items-center justify-center text-black/50 dark:text-white/60 [&_svg]:size-4">
                  {command.icon ?? <Slash />}
                </span>
                <span className="shrink-0 text-black/80 text-sm dark:text-white/80">
                  <MatchedLabel label={command.label} query={query ?? ""} />
                </span>
                {command.description && (
                  <span className="min-w-0 truncate text-black/40 text-xs dark:text-white/40">
                    {command.description}
                  </span>
                )}
              </div>
            ))}
          </div>
          <div className="mt-1 flex items-center gap-3 border-black/5 border-t px-2 pt-1.5 pb-1 text-[11px] text-black/40 dark:border-white/5 dark:text-white/40">
            <span className="flex items-center gap-1">
              <Kbd>↑</Kbd>
              <Kbd>↓</Kbd>
              to move
            </span>
            <span className="flex items-center gap-1">
              <Kbd>↵</Kbd>
              to pick
            </span>
            <span className="flex items-center gap-1">
              <Kbd>esc</Kbd>
              to close
            </span>
          </div>
        </motion.div>
      );
    }
    
    function CommandPill({
      command,
      onRemove,
    }: {
      command: PromptCommand;
      onRemove: () => void;
    }) {
      return (
        <motion.li
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          className="flex h-11 items-center gap-1.5 rounded-xl bg-blue-500/10 pr-1.5 pl-2.5 text-blue-600 text-sm dark:text-blue-400"
          exit={{
            opacity: 0,
            filter: "blur(2px)",
            transition: { duration: 0.12, ease: EASE_OUT },
          }}
          initial={{ opacity: 0, y: 4, filter: "blur(2px)" }}
          layout="position"
          transition={{ duration: 0.2, ease: EASE_OUT }}
        >
          <span aria-hidden="true" className="opacity-60">
            /
          </span>
          {command.label}
          <button
            aria-label={`Remove ${command.label} command`}
            className="flex size-6 items-center justify-center rounded-md transition-colors duration-150 hover:bg-blue-500/10 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-blue-500"
            onClick={onRemove}
            type="button"
          >
            <X aria-hidden="true" className="size-3.5" />
          </button>
        </motion.li>
      );
    }
    
    function SendButton({
      responding,
      canSend,
      onSend,
      onStop,
    }: {
      responding: boolean;
      canSend: boolean;
      onSend: () => void;
      onStop: () => void;
    }) {
      // One button with two faces. The faces crossfade with a little blur, so
      // Send turning into Stop reads as the same control changing state.
      const face =
        "absolute inset-0 flex items-center justify-center transition-[opacity,filter] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]";
      return (
        <button
          aria-label={responding ? "Stop response" : "Send message"}
          className={cn(
            "relative size-8 shrink-0 rounded-full",
            "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60",
            "transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
            // Fills in once there is something to send, so the button says
            // "ready" before it is pressed. Stop keeps the same fill.
            responding || canSend
              ? "bg-black text-white hover:bg-black/80 dark:bg-white dark:text-black dark:hover:bg-white/85"
              : "bg-black/5 text-black/30 dark:bg-white/10 dark:text-white/30"
          )}
          disabled={!(responding || canSend)}
          onClick={responding ? onStop : onSend}
          type="button"
        >
          <span
            aria-hidden="true"
            className={cn(
              face,
              responding ? "opacity-0 blur-[2px]" : "opacity-100 blur-0"
            )}
          >
            <ArrowUp className="size-4" strokeWidth={2.25} />
          </span>
          <span
            aria-hidden="true"
            className={cn(
              face,
              responding ? "opacity-100 blur-0" : "opacity-0 blur-[2px]"
            )}
          >
            <span className="size-2.5 rounded-[2px] bg-current" />
          </span>
        </button>
      );
    }
    
    function PromoHeader({
      model,
      text,
      action,
      canSwitch,
      inUse,
      onAction,
    }: {
      model: string;
      text: string;
      action: string;
      /** False when the promoted model is not in the menu: no button then. */
      canSwitch: boolean;
      inUse: boolean;
      onAction: () => void;
    }) {
      return (
        <div className="flex h-9 items-center gap-2 pr-1.5 pl-3 text-xs">
          <span className="flex size-3.5 shrink-0 items-center justify-center text-black dark:text-white [&_svg]:size-3.5">
            {getModelIcon(model)}
          </span>
          <p className="min-w-0 flex-1 truncate text-black/60 dark:text-white/60">
            {text}
          </p>
          {action && canSwitch && (
            <button
              className={cn(
                "flex h-6 shrink-0 items-center gap-1 rounded-md px-2 font-medium",
                "transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
                "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-blue-500",
                inUse
                  ? "text-black/40 dark:text-white/40"
                  : "text-black hover:bg-black/5 dark:text-white dark:hover:bg-white/10"
              )}
              disabled={inUse}
              onClick={onAction}
              type="button"
            >
              {inUse ? (
                <>
                  <Check aria-hidden="true" className="size-3" />
                  In use
                </>
              ) : (
                action
              )}
            </button>
          )}
        </div>
      );
    }
    
    /** Rising bars, filled up to the current effort, like a signal meter. */
    function EffortBars({ count, level }: { count: number; level: number }) {
      return (
        <span aria-hidden="true" className="flex h-3 items-end gap-[2px]">
          {Array.from({ length: count }, (_, i) => (
            <span
              className={cn(
                "w-[3px] rounded-[1px] transition-colors duration-200",
                i <= level
                  ? "bg-black/80 dark:bg-white/85"
                  : "bg-black/15 dark:bg-white/20"
              )}
              // biome-ignore lint/suspicious/noArrayIndexKey: fixed bars, never reordered
              key={i}
              style={{ height: `${((i + 1) / count) * 100}%` }}
            />
          ))}
        </span>
      );
    }
    
    const ghostButton = cn(
      "flex h-8 items-center gap-1.5 rounded-full px-2.5 text-black/70 text-xs dark:text-white/70",
      "hover:bg-black/5 hover:text-black data-[state=open]:bg-black/5 data-[state=open]:text-black",
      "dark:data-[state=open]:bg-white/10 dark:data-[state=open]:text-white dark:hover:bg-white/10 dark:hover:text-white",
      "transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]",
      "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60"
    );
    
    const menuPanel =
      "origin-(--radix-dropdown-menu-content-transform-origin) rounded-xl border-black/10 p-1 shadow-[0_8px_24px_-8px_rgb(0_0_0/0.18)] dark:border-white/10 dark:bg-zinc-900";
    
    function ModelMenu({
      models,
      selected,
      onSelect,
    }: {
      models: PromptModel[];
      selected: string;
      onSelect: (model: PromptModel) => void;
    }) {
      return (
        <DropdownMenu>
          {/* Kryptonite UI change: the trigger is styled directly instead of using
              asChild, because the shadcn CLI rewrites asChild to Base UI's render
              prop on install, which this Radix trigger doesn't accept. */}
          <DropdownMenuTrigger className={cn(ghostButton, "group pr-2")}>
              <span className="flex items-center [&_svg]:size-3.5">
                {getModelIcon(selected)}
              </span>
              {/* Only the name swaps; the chevron stays put. */}
              <span className="relative inline-grid">
                <AnimatePresence initial={false} mode="popLayout">
                  <motion.span
                    animate={{ opacity: 1, filter: "blur(0px)" }}
                    className="col-start-1 row-start-1 whitespace-nowrap font-medium"
                    exit={{ opacity: 0, filter: "blur(2px)" }}
                    initial={{ opacity: 0, filter: "blur(2px)" }}
                    key={selected}
                    transition={{ duration: 0.15, ease: EASE_OUT }}
                  >
                    {selected}
                  </motion.span>
                </AnimatePresence>
              </span>
              <ChevronDown
                aria-hidden="true"
                className="size-3 opacity-50 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-data-[state=open]:rotate-180"
              />
          </DropdownMenuTrigger>
          {/* Opens upward, as the prompt sits at the bottom of a chat, and
              grows out of its trigger. */}
          <DropdownMenuContent
            align="start"
            className={cn(menuPanel, "w-72")}
            side="top"
            sideOffset={8}
          >
            <DropdownMenuLabel className="px-2 pt-1.5 pb-1 font-normal text-[11px] text-black/40 dark:text-white/40">
              Model
            </DropdownMenuLabel>
            {models.map((model) => (
              <DropdownMenuItem
                className="flex items-center gap-2.5 rounded-lg px-2 py-1.5"
                key={model.name}
                onSelect={() => onSelect(model)}
              >
                <span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-3.5">
                  {getModelIcon(model.name)}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block text-black text-xs dark:text-white">
                    {model.name}
                  </span>
                  {model.description && (
                    <span className="block truncate text-[11px] text-black/45 dark:text-white/45">
                      {model.description}
                    </span>
                  )}
                </span>
                <Check
                  aria-hidden="true"
                  className={cn(
                    "size-3.5 shrink-0 text-black dark:text-white",
                    model.name !== selected && "invisible"
                  )}
                />
              </DropdownMenuItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      );
    }
    
    /**
     * How hard the model thinks. A separate control from the model, so it can
     * be turned up for one hard question without opening the model list.
     */
    function EffortMenu({
      efforts,
      value,
      onChange,
    }: {
      efforts: string[];
      value: string;
      onChange: (effort: string) => void;
    }) {
      const level = Math.max(0, efforts.indexOf(value));
      return (
        <DropdownMenu>
          <DropdownMenuTrigger aria-label={`Effort: ${value}. Change`} className={ghostButton}>
            <EffortBars count={efforts.length} level={level} />
            <span className="tabular-nums">{value}</span>
          </DropdownMenuTrigger>
          <DropdownMenuContent
            align="start"
            className={cn(menuPanel, "w-60")}
            side="top"
            sideOffset={8}
          >
            <DropdownMenuLabel className="px-2 pt-1.5 pb-1 font-normal text-[11px] text-black/40 dark:text-white/40">
              Effort
            </DropdownMenuLabel>
            {/* Highest first, so the list reads like the bars: up is more. */}
            <DropdownMenuRadioGroup onValueChange={onChange} value={value}>
              {[...efforts].reverse().map((effort) => {
                const index = efforts.indexOf(effort);
                return (
                  <DropdownMenuRadioItem
                    className="flex items-center gap-2.5 rounded-lg py-1.5 pr-2 pl-2 [&>span:first-child]:hidden"
                    key={effort}
                    value={effort}
                  >
                    <EffortBars count={efforts.length} level={index} />
                    <span className="min-w-0 flex-1">
                      <span className="block text-black text-xs dark:text-white">
                        {effort}
                      </span>
                      {EFFORT_HINTS[effort] && (
                        <span className="block truncate text-[11px] text-black/45 dark:text-white/45">
                          {EFFORT_HINTS[effort]}
                        </span>
                      )}
                    </span>
                    <Check
                      aria-hidden="true"
                      className={cn(
                        "size-3.5 shrink-0 text-black dark:text-white",
                        effort !== value && "invisible"
                      )}
                    />
                  </DropdownMenuRadioItem>
                );
              })}
            </DropdownMenuRadioGroup>
          </DropdownMenuContent>
        </DropdownMenu>
      );
    }
    
    export default function AI_Prompt({
      models = DEFAULT_MODELS,
      defaultModel = "Claude Opus 5.5",
      placeholder = "Ask anything. Type / for commands",
      headerText = "Claude Fable 5.1 is free this weekend",
      headerAction = "Try it",
      promotedModel = "Claude Fable 5.1",
      onSubmit,
      onStop,
      onAttach,
      commands = DEFAULT_COMMANDS,
      className,
    }: AIPromptProps) {
      const [value, setValue] = useState("");
      const [command, setCommand] = useState<PromptCommand | null>(null);
      const [menuIndex, setMenuIndex] = useState(0);
      const [menuDismissed, setMenuDismissed] = useState(false);
      const menuId = useId();
      const [attachments, setAttachments] = useState<PromptAttachment[]>([]);
      const [responding, setResponding] = useState(false);
      const [isDragging, setIsDragging] = useState(false);
      const abortRef = useRef<AbortController | null>(null);
      const nextIdRef = useRef(0);
      // The last message sent, for ↑ in an empty prompt (as in a terminal).
      const lastSentRef = useRef("");
      const { textareaRef, adjustHeight } = useAutoResizeTextarea({
        minHeight: 56,
        maxHeight: 280,
      });
      // A default that is not in `models` would show a model the menu can't pick.
      const [selectedModel, setSelectedModel] = useState(() =>
        models.some((m) => m.name === defaultModel)
          ? defaultModel
          : (models[0]?.name ?? defaultModel)
      );
      const currentModel = models.find((m) => m.name === selectedModel);
      const [effort, setEffort] = useState(() => startEffort(currentModel));
      const textareaId = useId();
      const shouldReduceMotion = useReducedMotion();
    
      useEffect(() => () => abortRef.current?.abort(), []);
    
      const pickModel = (next: PromptModel) => {
        setEffort(carryEffort(currentModel, next, effort));
        setSelectedModel(next.name);
      };
    
      const addFiles = (files: FileList | File[]) => {
        const list = Array.from(files);
        if (list.length === 0) {
          return;
        }
        onAttach?.(files);
        setAttachments((current) => [
          ...current,
          ...list.map((file) => {
            nextIdRef.current += 1;
            return { id: `attachment-${nextIdRef.current}`, file };
          }),
        ]);
      };
    
      const removeAttachment = (id: string) => {
        setAttachments((current) => current.filter((a) => a.id !== id));
        textareaRef.current?.focus();
      };
    
      // "/" at the very start opens the menu; the text after it filters it.
      const slashQuery =
        !command && value.startsWith("/") && !WHITESPACE.test(value)
          ? value.slice(1).toLowerCase()
          : null;
      const matches =
        slashQuery === null
          ? []
          : commands.filter((c) => c.label.toLowerCase().includes(slashQuery));
      const menuOpen = matches.length > 0 && !menuDismissed;
      const activeIndex = Math.min(menuIndex, matches.length - 1);
    
      const pickCommand = (picked: PromptCommand) => {
        setCommand(picked);
        setValue("");
        setMenuIndex(0);
        adjustHeight(true);
        textareaRef.current?.focus();
      };
    
      // A dropped screenshot with no text is still a message.
      const canSend =
        (value.trim().length > 0 || attachments.length > 0) && !responding;
    
      const submit = async () => {
        if (!canSend) {
          return;
        }
        const controller = new AbortController();
        abortRef.current = controller;
        const files = attachments.map((a) => a.file);
        const sentCommand = command;
        lastSentRef.current = value;
        setValue("");
        setAttachments([]);
        setCommand(null);
        adjustHeight(true);
        setResponding(true);
        try {
          await (onSubmit
            ? onSubmit(value, selectedModel, {
                effort,
                files,
                signal: controller.signal,
                command: sentCommand,
              })
            : waitFor(DEMO_REPLY_MS, controller.signal));
        } finally {
          if (abortRef.current === controller) {
            abortRef.current = null;
            setResponding(false);
          }
        }
      };
    
      const stop = () => {
        abortRef.current?.abort();
        abortRef.current = null;
        setResponding(false);
        onStop?.();
        textareaRef.current?.focus();
      };
    
      const handleMenuKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
        const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
        if (step) {
          e.preventDefault();
          setMenuIndex((activeIndex + step + matches.length) % matches.length);
          return true;
        }
        if (e.key === "Enter" || e.key === "Tab") {
          e.preventDefault();
          pickCommand(matches[activeIndex]);
          return true;
        }
        if (e.key === "Escape") {
          e.preventDefault();
          setMenuDismissed(true);
          return true;
        }
        return false;
      };
    
      // ↑ in an empty prompt brings the last message back to edit.
      const recallLast = () => {
        setValue(lastSentRef.current);
        // After React writes the value: grow to fit, caret at the end.
        requestAnimationFrame(() => {
          const el = textareaRef.current;
          adjustHeight();
          el?.setSelectionRange(el.value.length, el.value.length);
        });
      };
    
      const handleEmptyKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
        if (e.key === "ArrowUp" && lastSentRef.current) {
          e.preventDefault();
          recallLast();
          return true;
        }
        // Backspace in an empty prompt takes the command off first.
        if (e.key === "Backspace" && command) {
          e.preventDefault();
          setCommand(null);
          return true;
        }
        return false;
      };
    
      const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
        // Enter during IME composition confirms the candidate; it must not submit.
        if (e.nativeEvent.isComposing) {
          return;
        }
        if (menuOpen && handleMenuKey(e)) {
          return;
        }
        if (value === "" && handleEmptyKey(e)) {
          return;
        }
        if (e.key === "Enter" && !e.shiftKey) {
          e.preventDefault();
          submit();
        } else if (e.key === "Escape" && responding) {
          e.preventDefault();
          stop();
        }
      };
    
      const hasFiles = (e: React.DragEvent) =>
        Array.from(e.dataTransfer.types).includes("Files");
    
      const efforts = effortsOf(currentModel);
      const hasTray = attachments.length > 0 || command !== null;
    
      return (
        <div className={cn("w-full max-w-lg py-4", className)}>
          <div className="rounded-[22px] bg-black/[0.04] p-1 dark:bg-white/[0.04]">
            {headerText && (
              <PromoHeader
                action={headerAction}
                canSwitch={models.some((m) => m.name === promotedModel)}
                inUse={selectedModel === promotedModel}
                model={promotedModel}
                onAction={() => {
                  const promoted = models.find((m) => m.name === promotedModel);
                  if (promoted) {
                    pickModel(promoted);
                  }
                  textareaRef.current?.focus();
                }}
                text={headerText}
              />
            )}
            {/* biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/noNoninteractiveElementInteractions: drop target for files; keyboard users have the attach button */}
            <div
              className={cn(
                "relative flex flex-col rounded-[18px] bg-white ring-1 ring-black/[0.08] dark:bg-zinc-900 dark:ring-white/10",
                "shadow-[0_1px_2px_rgb(0_0_0/0.04),0_6px_20px_-10px_rgb(0_0_0/0.12)]",
                "transition-shadow duration-150 has-[textarea:focus-visible]:ring-black/20 dark:has-[textarea:focus-visible]:ring-white/25",
                isDragging && "ring-blue-500/60 dark:ring-blue-400/60"
              )}
              onDragLeave={(e) => {
                if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
                  setIsDragging(false);
                }
              }}
              onDragOver={(e) => {
                if (hasFiles(e)) {
                  e.preventDefault();
                  setIsDragging(true);
                }
              }}
              onDrop={(e) => {
                if (!hasFiles(e)) {
                  return;
                }
                e.preventDefault();
                setIsDragging(false);
                addFiles(e.dataTransfer.files);
              }}
            >
              <AnimatePresence initial={false}>
                {hasTray && (
                  <motion.div
                    animate={{ height: "auto", opacity: 1 }}
                    className="overflow-hidden"
                    exit={{ height: 0, opacity: 0 }}
                    initial={{ height: 0, opacity: 0 }}
                    transition={{
                      duration: shouldReduceMotion ? 0 : 0.2,
                      ease: EASE_OUT,
                    }}
                  >
                    <ul
                      aria-label="Attachments"
                      className="flex flex-wrap gap-2 px-3 pt-3"
                    >
                      <AnimatePresence initial={false} mode="popLayout">
                        {command && (
                          <CommandPill
                            command={command}
                            key={`command-${command.id}`}
                            onRemove={() => {
                              setCommand(null);
                              textareaRef.current?.focus();
                            }}
                          />
                        )}
                        {attachments.map((attachment) => (
                          <AttachmentChip
                            attachment={attachment}
                            key={attachment.id}
                            onRemove={() => removeAttachment(attachment.id)}
                          />
                        ))}
                      </AnimatePresence>
                    </ul>
                  </motion.div>
                )}
              </AnimatePresence>
              <label className="sr-only" htmlFor={textareaId}>
                Message
              </label>
              <AnimatePresence>
                {menuOpen && (
                  <CommandMenu
                    activeIndex={activeIndex}
                    commands={matches}
                    id={menuId}
                    onHover={setMenuIndex}
                    onSelect={pickCommand}
                    query={slashQuery}
                  />
                )}
              </AnimatePresence>
              <Textarea
                aria-activedescendant={
                  menuOpen ? `${menuId}-${matches[activeIndex].id}` : undefined
                }
                aria-autocomplete="list"
                aria-controls={menuOpen ? menuId : undefined}
                aria-expanded={menuOpen}
                // shadow-none and md:text-[15px] beat the shadow-xs and
                // md:text-sm of the current shadcn Textarea. Touch screens get 16px so
                // iOS does not zoom into the field on focus.
                className="min-h-[56px] w-full resize-none rounded-none border-none bg-transparent px-4 pt-3.5 pb-1 text-[15px] text-black leading-relaxed shadow-none placeholder:text-black/35 focus-visible:ring-0 focus-visible:ring-offset-0 md:text-[15px] dark:bg-transparent dark:text-white dark:placeholder:text-white/35 [@media(pointer:coarse)]:text-base"
                id={textareaId}
                onBlur={() => setMenuDismissed(true)}
                onChange={(e) => {
                  setValue(e.target.value);
                  setMenuDismissed(false);
                  setMenuIndex(0);
                  adjustHeight();
                }}
                onFocus={() => setMenuDismissed(false)}
                onKeyDown={handleKeyDown}
                onPaste={(e) => {
                  if (e.clipboardData.files.length > 0) {
                    e.preventDefault();
                    addFiles(e.clipboardData.files);
                  }
                }}
                placeholder={
                  responding ? "Replying… Esc to stop, or keep typing" : placeholder
                }
                ref={textareaRef}
                value={value}
              />
    
              <div className="flex items-center gap-1 px-2.5 pt-1 pb-2.5">
                <label
                  className={cn(
                    "flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full ring-1 ring-black/10 dark:ring-white/15",
                    "text-black/60 hover:bg-black/5 hover:text-black dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white",
                    "has-focus-visible:ring-2 has-focus-visible:ring-blue-500/60",
                    "transition-[background-color,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
                  )}
                  title="Attach files, or drop or paste them"
                >
                  <input
                    aria-label="Attach files"
                    className="sr-only"
                    multiple
                    onChange={(e) => {
                      if (e.target.files?.length) {
                        addFiles(e.target.files);
                      }
                      // Cleared so picking the same file again still fires.
                      e.target.value = "";
                    }}
                    type="file"
                  />
                  <Plus aria-hidden="true" className="size-4" />
                </label>
                <ModelMenu
                  models={models}
                  onSelect={pickModel}
                  selected={selectedModel}
                />
                {efforts.length > 0 && effort && (
                  <EffortMenu
                    efforts={efforts}
                    onChange={setEffort}
                    value={effort}
                  />
                )}
                <div className="flex-1" />
                <SendButton
                  canSend={canSend}
                  onSend={submit}
                  onStop={stop}
                  responding={responding}
                />
              </div>
    
              {/* Shown while files are dragged over the prompt. */}
              <div
                aria-hidden="true"
                className={cn(
                  "pointer-events-none absolute inset-0 flex items-center justify-center rounded-[18px] border border-blue-500/50 border-dashed bg-white/85 text-blue-600 text-xs transition-opacity duration-150 dark:bg-zinc-900/85 dark:text-blue-400",
                  isDragging ? "opacity-100" : "opacity-0"
                )}
              >
                Drop files to attach
              </div>
            </div>
          </div>
        </div>
      );
    }
  3. Copy this into components/kokonut/ui/dropdown-menu.tsx:

    dropdown-menu.tsx
    "use client";
    
    import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
    import { Check, ChevronRight, Circle } from "lucide-react";
    import * as React from "react";
    
    import { cn } from "@/lib/utils";
    
    const DropdownMenu = DropdownMenuPrimitive.Root;
    
    const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
    
    const DropdownMenuGroup = DropdownMenuPrimitive.Group;
    
    const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
    
    const DropdownMenuSub = DropdownMenuPrimitive.Sub;
    
    const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
    
    const DropdownMenuSubTrigger = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
        inset?: boolean;
      }
    >(({ className, inset, children, ...props }, ref) => (
      <DropdownMenuPrimitive.SubTrigger
        className={cn(
          "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-zinc-100 data-[state=open]:bg-zinc-100 dark:data-[state=open]:bg-zinc-800 dark:focus:bg-zinc-800 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
          inset && "pl-8",
          className
        )}
        ref={ref}
        {...props}
      >
        {children}
        <ChevronRight className="ml-auto" />
      </DropdownMenuPrimitive.SubTrigger>
    ));
    DropdownMenuSubTrigger.displayName =
      DropdownMenuPrimitive.SubTrigger.displayName;
    
    const DropdownMenuSubContent = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
    >(({ className, ...props }, ref) => (
      <DropdownMenuPrimitive.SubContent
        className={cn(
          "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-zinc-950 shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50",
          className
        )}
        ref={ref}
        {...props}
      />
    ));
    DropdownMenuSubContent.displayName =
      DropdownMenuPrimitive.SubContent.displayName;
    
    const DropdownMenuContent = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.Content>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
    >(({ className, sideOffset = 4, ...props }, ref) => (
      <DropdownMenuPrimitive.Portal>
        <DropdownMenuPrimitive.Content
          className={cn(
            "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border border-zinc-200 bg-white p-1 text-zinc-950 shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50",
            className
          )}
          ref={ref}
          sideOffset={sideOffset}
          {...props}
        />
      </DropdownMenuPrimitive.Portal>
    ));
    DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
    
    const DropdownMenuItem = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.Item>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
        inset?: boolean;
      }
    >(({ className, inset, ...props }, ref) => (
      <DropdownMenuPrimitive.Item
        className={cn(
          "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-zinc-100 focus:text-zinc-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-zinc-800 dark:focus:text-zinc-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
          inset && "pl-8",
          className
        )}
        ref={ref}
        {...props}
      />
    ));
    DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
    
    const DropdownMenuCheckboxItem = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
    >(({ className, children, checked, ...props }, ref) => (
      <DropdownMenuPrimitive.CheckboxItem
        checked={checked}
        className={cn(
          "relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-zinc-100 focus:text-zinc-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-zinc-800 dark:focus:text-zinc-50",
          className
        )}
        ref={ref}
        {...props}
      >
        <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
          <DropdownMenuPrimitive.ItemIndicator>
            <Check className="h-4 w-4" />
          </DropdownMenuPrimitive.ItemIndicator>
        </span>
        {children}
      </DropdownMenuPrimitive.CheckboxItem>
    ));
    DropdownMenuCheckboxItem.displayName =
      DropdownMenuPrimitive.CheckboxItem.displayName;
    
    const DropdownMenuRadioItem = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
    >(({ className, children, ...props }, ref) => (
      <DropdownMenuPrimitive.RadioItem
        className={cn(
          "relative flex cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none transition-colors focus:bg-zinc-100 focus:text-zinc-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-zinc-800 dark:focus:text-zinc-50",
          className
        )}
        ref={ref}
        {...props}
      >
        <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
          <DropdownMenuPrimitive.ItemIndicator>
            <Circle className="h-2 w-2 fill-current" />
          </DropdownMenuPrimitive.ItemIndicator>
        </span>
        {children}
      </DropdownMenuPrimitive.RadioItem>
    ));
    DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
    
    const DropdownMenuLabel = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.Label>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
        inset?: boolean;
      }
    >(({ className, inset, ...props }, ref) => (
      <DropdownMenuPrimitive.Label
        className={cn(
          "px-2 py-1.5 font-semibold text-sm",
          inset && "pl-8",
          className
        )}
        ref={ref}
        {...props}
      />
    ));
    DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
    
    const DropdownMenuSeparator = React.forwardRef<
      React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
      React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
    >(({ className, ...props }, ref) => (
      <DropdownMenuPrimitive.Separator
        className={cn("-mx-1 my-1 h-px bg-zinc-100 dark:bg-zinc-800", className)}
        ref={ref}
        {...props}
      />
    ));
    DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
    
    const DropdownMenuShortcut = ({
      className,
      ...props
    }: React.HTMLAttributes<HTMLSpanElement>) => {
      return (
        <span
          className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
          {...props}
        />
      );
    };
    DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
    
    export {
      DropdownMenu,
      DropdownMenuTrigger,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuCheckboxItem,
      DropdownMenuRadioItem,
      DropdownMenuLabel,
      DropdownMenuSeparator,
      DropdownMenuShortcut,
      DropdownMenuGroup,
      DropdownMenuPortal,
      DropdownMenuSub,
      DropdownMenuSubContent,
      DropdownMenuSubTrigger,
      DropdownMenuRadioGroup,
    };
  4. Copy this into components/kokonut/ui/textarea.tsx:

    textarea.tsx
    import * as React from "react";
    
    import { cn } from "@/lib/utils";
    
    export interface TextareaProps
      extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
    
    const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
      ({ className, ...props }, ref) => {
        return (
          <textarea
            className={cn(
              "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
              className
            )}
            ref={ref}
            {...props}
          />
        );
      }
    );
    Textarea.displayName = "Textarea";
    
    export { Textarea };
  5. Copy this into components/kokonut/hooks/use-auto-resize-textarea.ts:

    use-auto-resize-textarea.ts
    import { useCallback, useEffect, useRef } from "react";
    
    interface UseAutoResizeTextareaProps {
      minHeight: number;
      maxHeight?: number;
    }
    
    export function useAutoResizeTextarea({
      minHeight,
      maxHeight,
    }: UseAutoResizeTextareaProps) {
      const textareaRef = useRef<HTMLTextAreaElement>(null);
    
      const adjustHeight = useCallback(
        (reset?: boolean) => {
          const textarea = textareaRef.current;
          if (!textarea) {
            return;
          }
    
          if (reset) {
            textarea.style.height = `${minHeight}px`;
            return;
          }
    
          // Temporarily shrink to get the right scrollHeight
          textarea.style.height = `${minHeight}px`;
    
          // Calculate new height
          const newHeight = Math.max(
            minHeight,
            Math.min(textarea.scrollHeight, maxHeight ?? Number.POSITIVE_INFINITY)
          );
    
          textarea.style.height = `${newHeight}px`;
        },
        [minHeight, maxHeight]
      );
    
      useEffect(() => {
        // Set initial height
        const textarea = textareaRef.current;
        if (textarea) {
          textarea.style.height = `${minHeight}px`;
        }
      }, [minHeight]);
    
      // Adjust height on window resize
      useEffect(() => {
        const handleResize = () => adjustHeight();
        window.addEventListener("resize", handleResize);
        return () => window.removeEventListener("resize", handleResize);
      }, [adjustHeight]);
    
      return { textareaRef, adjustHeight };
    }

Feedback

Questions, bugs or ideas for AI Input Selector? Post them here, or reply to someone else. Sign in with GitHub to comment.