# AI Agent Input

A composer field with attach + model-switch menu and an “enhance prompt” working state — idle, filled, enhancing and enhanced.

- Category: Rich & Interactive
- Source: AIcss (http://localhost:3000/components/ai-agent-input)
- Author: @kvnkld (https://x.com/kvnkld)
- Styling: Plain CSS with CSS custom properties (design tokens). No Tailwind, no UI libraries, no external dependencies. Theme-aware via [data-theme] (light/dark).

## Instructions

Add this component to the project. Keep the styling self-contained and do not introduce Tailwind or other UI dependencies. Map the design tokens (CSS custom properties) to the project's theme if they are not already defined.

## Code

### React — `PromptInput.tsx`

```tsx
import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type MouseEvent as ReactMouseEvent,
} from "react";
import {
  ArrowUp,
  BookOpen,
  Check,
  ChevronRight,
  Image as ImageIcon,
  Loader2,
  Paperclip,
  Plus,
  X,
} from "lucide-react";
import styles from "./PromptInput.module.css";

const ENHANCED =
  "This is an example prompt — rewritten to be clear and specific: state the goal, add the relevant context and constraints, define the expected output format and tone, and note any assumptions. Ask a clarifying question first if key details are missing.";

/**
 * Turn a raw prompt into an improved one. This is the integration seam:
 * replace the mock body with a real request to your model/API. The component
 * only depends on it resolving to the enhanced prompt string (and honouring
 * the AbortSignal so an in-flight call can be cancelled).
 */
async function mockEnhance(prompt: string, signal?: AbortSignal): Promise<string> {
  // --- MOCK (demo only) — remove when wiring a real backend ----------
  await new Promise((r) => setTimeout(r, 2500));
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
  return ENHANCED;
  // --- REAL API (example) --------------------------------------------
  // const res = await fetch("/api/enhance", {
  //   method: "POST",
  //   headers: { "Content-Type": "application/json" },
  //   body: JSON.stringify({ prompt }),
  //   signal,
  // });
  // if (!res.ok) throw new Error("Enhance request failed");
  // return (await res.json()).prompt as string;
}

const MODELS = [
  {
    id: "claude-opus-4.8",
    name: "Claude Opus 4.8",
    desc: "Anthropic's most capable model — best for complex, multi-step reasoning.",
    context: "200k context window",
  },
  {
    id: "gpt-5.6",
    name: "GPT-5.6",
    desc: "OpenAI's flagship — strong all-round performance and tool use.",
    context: "400k context window",
  },
  {
    id: "gemini-2.5-pro",
    name: "Gemini 2.5 Pro",
    desc: "Google's long-context model — great for large documents and codebases.",
    context: "1M context window",
  },
];

const SKILLS = [
  { id: "deep-research", name: "Deep Research" },
  { id: "code-review", name: "Code Review" },
  { id: "web-search", name: "Web Search" },
  { id: "summarize", name: "Summarize" },
];

// Official brand marks (from Wikimedia Commons). ChatGPT is monochrome and
// follows the text colour; Claude uses Anthropic's orange; Gemini keeps its
// original radial gradient.
function ModelIcon({ id }: { id: string }) {
  if (id.startsWith("gpt")) {
    return (
      <svg width="12" height="12" viewBox="0 0 320 320" fill="currentColor" aria-hidden="true">
        <path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
      </svg>
    );
  }
  if (id.startsWith("claude")) {
    return (
      <svg width="12" height="12" viewBox="0 0 100 100" fill="#d97757" aria-hidden="true">
        <path d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z" />
      </svg>
    );
  }
  return (
    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
      <path d="M16 8.016A8.522 8.522 0 0 0 8.016 16h-.032A8.521 8.521 0 0 0 0 8.016v-.032A8.521 8.521 0 0 0 7.984 0h.032A8.522 8.522 0 0 0 16 7.984v.032z" fill="url(#pi-gemini-grad)" />
      <defs>
        <radialGradient
          id="pi-gemini-grad"
          cx="0"
          cy="0"
          r="1"
          gradientUnits="userSpaceOnUse"
          gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"
        >
          <stop offset=".067" stopColor="#9168C0" />
          <stop offset=".343" stopColor="#5684D1" />
          <stop offset=".672" stopColor="#1BA1E3" />
        </radialGradient>
      </defs>
    </svg>
  );
}

const skillName = (id: string) => SKILLS.find((sk) => sk.id === id)?.name ?? id;

const escapeHtml = (str: string) =>
  str.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] ?? c));

type Phase = "idle" | "enhancing" | "enhanced";
type Attachment = { id: number; name: string; kind: "image" | "file" };

export function PromptInput({
  onEnhance = mockEnhance,
}: {
  onEnhance?: (prompt: string, signal?: AbortSignal) => Promise<string>;
} = {}) {
  // `value` mirrors the editor's plain text (skill pills contribute their
  // label), so it drives the empty/placeholder + enhance/send logic.
  const [value, setValue] = useState("");
  const [phase, setPhase] = useState<Phase>("idle");
  const [menuOpen, setMenuOpen] = useState(false);
  const [skillsOpen, setSkillsOpen] = useState(false);
  const [hoveredModel, setHoveredModel] = useState<string | null>(null);
  const [model, setModel] = useState(MODELS[0].id);
  const [attachments, setAttachments] = useState<Attachment[]>([]);
  // ids of chips currently playing their exit animation before removal
  const [exitingAtt, setExitingAtt] = useState<number[]>([]);

  // Keep the enhance pill mounted through a short exit so it leaves the same
  // soft way it arrives (mirrors pi-pill-in / pi-pill-out).
  const [pillMounted, setPillMounted] = useState(false);
  const [pillExiting, setPillExiting] = useState(false);

  // Slash-command palette (typing "/" opens the same skill picker).
  const [slashOpen, setSlashOpen] = useState(false);
  const [slashQuery, setSlashQuery] = useState("");
  const [slashIndex, setSlashIndex] = useState(0);
  const [slashKeyboard, setSlashKeyboard] = useState(false);

  const editorRef = useRef<HTMLDivElement>(null);
  const frameRef = useRef<HTMLDivElement>(null);
  const plusRef = useRef<HTMLDivElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);
  const preEnhanceHTML = useRef("");
  const pendingHTML = useRef<string | null>(null);
  // height of the frame captured right before an enhance/revert swap, so the
  // new height can be animated from it (FLIP) instead of jumping.
  const flipFrom = useRef<number | null>(null);
  const savedRange = useRef<Range | null>(null);
  const abortRef = useRef<AbortController | null>(null);
  const nextId = useRef(1);
  const slashOpenRef = useRef(false);
  const slashIndexRef = useRef(0);
  const slashResultsRef = useRef<typeof SKILLS>([]);
  const slashQueryRef = useRef("");
  const slashTokenRef = useRef<{ node: Text; start: number; end: number } | null>(null);
  const ignoreHoverRef = useRef(false);
  const applySlashRef = useRef<(id: string) => void>(() => {});
  const slashKeyLock = useRef(false);

  const hasText = value.trim().length > 0;
  const enhancing = phase === "enhancing";
  const sendActive = hasText && !enhancing;
  const showPill = hasText && !enhancing;
  const slashResults = SKILLS.filter((sk) =>
    sk.name.toLowerCase().includes(slashQuery.toLowerCase())
  );
  slashOpenRef.current = slashOpen;
  slashIndexRef.current = slashIndex;
  slashResultsRef.current = slashResults;

  // Focus the editor and drop the caret at the very end of its content.
  const focusEnd = () => {
    const editor = editorRef.current;
    if (!editor) return;
    editor.focus();
    const range = document.createRange();
    range.selectNodeContents(editor);
    range.collapse(false);
    const sel = window.getSelection();
    sel?.removeAllRanges();
    sel?.addRange(range);
    savedRange.current = range.cloneRange();
  };

  const syncFromEditor = () => {
    const editor = editorRef.current;
    if (!editor) return;
    setValue(editor.textContent ?? "");
    // Mark pills that sit at the very start (nothing but whitespace before them)
    // so CSS can drop their left margin — :first-child can't see text nodes.
    editor.querySelectorAll<HTMLElement>("." + styles.skillPill).forEach((pill) => {
      let atStart = true;
      for (let n = pill.previousSibling; n; n = n.previousSibling) {
        if (n.nodeType === Node.TEXT_NODE && (n.textContent ?? "").trim() === "") continue;
        atStart = false;
        break;
      }
      pill.toggleAttribute("data-start", atStart);
    });
  };

  // Remember the last caret position so the "+" menu can insert at it even
  // after the editor loses focus.
  const saveSelection = () => {
    const editor = editorRef.current;
    const sel = window.getSelection();
    if (sel && sel.rangeCount && editor && editor.contains(sel.anchorNode)) {
      savedRange.current = sel.getRangeAt(0).cloneRange();
    }
  };

  const closeSlash = () => {
    setSlashOpen(false);
    setSlashQuery("");
    setSlashIndex(0);
    setSlashKeyboard(false);
    slashQueryRef.current = "";
    slashTokenRef.current = null;
    ignoreHoverRef.current = false;
  };

  // Build a skill pill node (contenteditable=false so it deletes as a unit).
  const buildPill = (id: string) => {
    const name = skillName(id);
    const el = document.createElement("span");
    el.className = styles.skillPill;
    el.setAttribute("contenteditable", "false");
    el.dataset.skill = id;
    el.innerHTML =
      '<span class="' + styles.skillPillLabel + '">/' + escapeHtml(name) + "</span>" +
      '<button type="button" class="' + styles.skillPillX + '" data-remove="1" aria-label="Remove ' +
      escapeHtml(name) + '"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
    return el;
  };

  // Replace `range` with a pill + trailing space, then park the caret after it.
  const insertPillOverRange = (range: Range, id: string) => {
    const editor = editorRef.current;
    if (!editor) return;
    range.deleteContents();
    const pill = buildPill(id);
    range.insertNode(pill);
    const space = document.createTextNode("\u00A0");
    pill.after(space);
    const after = document.createRange();
    after.setStartAfter(space);
    after.collapse(true);
    const sel = window.getSelection();
    sel?.removeAllRanges();
    sel?.addRange(after);
    editor.focus();
    savedRange.current = after.cloneRange();
    syncFromEditor();
  };

  // Insert from the "+" menu: use the current/last caret, else append at end.
  const addSkillFromMenu = (id: string) => {
    const editor = editorRef.current;
    if (!editor) return;
    const sel = window.getSelection();
    let range: Range | null = null;
    if (sel && sel.rangeCount && editor.contains(sel.anchorNode)) {
      range = sel.getRangeAt(0).cloneRange();
    } else if (savedRange.current && editor.contains(savedRange.current.startContainer)) {
      range = savedRange.current.cloneRange();
    }
    if (!range) {
      range = document.createRange();
      range.selectNodeContents(editor);
      range.collapse(false);
    }
    insertPillOverRange(range, id);
    setMenuOpen(false);
  };

  // Insert from a "/" command: swallow the typed "/query" then drop the pill.
  const applySlash = (id: string) => {
    const editor = editorRef.current;
    if (!editor) {
      closeSlash();
      return;
    }
    let range: Range | null = null;
    const token = slashTokenRef.current;
    if (
      token &&
      token.node.isConnected &&
      editor.contains(token.node) &&
      token.end <= (token.node.textContent?.length ?? 0)
    ) {
      range = document.createRange();
      range.setStart(token.node, token.start);
      range.setEnd(token.node, token.end);
    } else {
      const sel = window.getSelection();
      if (sel && sel.rangeCount) {
        const caret = sel.getRangeAt(0);
        range = caret.cloneRange();
        const node = caret.startContainer;
        if (node.nodeType === Node.TEXT_NODE && editor.contains(node)) {
          const before = (node.textContent ?? "").slice(0, caret.startOffset);
          const m = before.match(/\/([^\s/]*)$/);
          if (m) {
            range = document.createRange();
            range.setStart(node, caret.startOffset - m[0].length);
            range.setEnd(node, caret.startOffset);
          }
        }
      }
    }
    if (!range) {
      closeSlash();
      return;
    }
    insertPillOverRange(range, id);
    closeSlash();
  };
  applySlashRef.current = applySlash;

  // Open the palette when the caret sits right after a "/" token.
  const detectSlash = () => {
    const editor = editorRef.current;
    const sel = window.getSelection();
    if (!editor || !sel || !sel.rangeCount || !sel.isCollapsed) return closeSlash();
    const range = sel.getRangeAt(0);
    const node = range.startContainer;
    if (node.nodeType !== Node.TEXT_NODE || !editor.contains(node)) return closeSlash();
    const before = (node.textContent ?? "").slice(0, range.startOffset);
    const m = before.match(/(?:^|\s)\/([^\s/]*)$/);
    if (!m) return closeSlash();
    const q = m[1];
    const slashStart = before.length - m[1].length - 1;
    slashTokenRef.current = {
      node: node as Text,
      start: slashStart,
      end: range.startOffset,
    };
    if (q !== slashQueryRef.current) {
      slashQueryRef.current = q;
      setSlashIndex(0);
    }
    setSlashQuery(q);
    setSlashOpen(true);
  };

  const onEditorInput = () => {
    syncFromEditor();
    if (phase === "enhanced") setPhase("idle");
    detectSlash();
  };

  const moveSlash = (delta: number) => {
    const results = slashResultsRef.current;
    if (!results.length) return;
    ignoreHoverRef.current = true;
    setSlashKeyboard(true);
    setSlashIndex((i) => (i + delta + results.length * 10) % results.length);
  };

  const handleSlashKey = (e: { key: string; preventDefault: () => void; stopPropagation?: () => void }) => {
    const results = slashResultsRef.current;
    if (!slashOpenRef.current || !results.length) return false;
    if (
      e.key !== "ArrowDown" &&
      e.key !== "ArrowUp" &&
      e.key !== "Enter" &&
      e.key !== "Tab" &&
      e.key !== "Escape"
    ) {
      return false;
    }
    e.preventDefault();
    e.stopPropagation?.();
    if (slashKeyLock.current) return true;
    slashKeyLock.current = true;
    queueMicrotask(() => {
      slashKeyLock.current = false;
    });
    if (e.key === "ArrowDown") {
      moveSlash(1);
      return true;
    }
    if (e.key === "ArrowUp") {
      moveSlash(-1);
      return true;
    }
    if (e.key === "Enter" || e.key === "Tab") {
      applySlashRef.current((results[slashIndexRef.current] ?? results[0]).id);
      return true;
    }
    if (e.key === "Escape") {
      closeSlash();
      return true;
    }
    return false;
  };

  const onEditorKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (handleSlashKey(e)) return;
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      send();
    }
  };

  useEffect(() => {
    if (!slashOpen) return;
    const onKey = (e: KeyboardEvent) => {
      handleSlashKey(e);
    };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, [slashOpen]);

  useEffect(() => {
    if (!slashOpen || !slashResults.length) return;
    if (slashIndex >= slashResults.length) setSlashIndex(0);
  }, [slashOpen, slashResults.length, slashIndex]);

  const onEditorClick = (e: ReactMouseEvent<HTMLDivElement>) => {
    const remove = (e.target as HTMLElement).closest("[data-remove]");
    if (remove) {
      e.preventDefault();
      const pill = remove.closest<HTMLElement>("[data-skill]");
      if (pill) {
        // the separator space we inserted right after the pill — drop it too
        // on removal so leftover spaces can't accumulate and shift the next
        // pill out of alignment.
        const sep = pill.nextSibling;
        // collapse the pill's footprint (width + margins + padding) in sync with
        // the fade so following text slides in smoothly instead of snapping.
        const w = pill.getBoundingClientRect().width;
        pill.style.maxWidth = `${w}px`;
        pill.style.overflow = "hidden";
        pill.style.whiteSpace = "nowrap";
        void pill.offsetWidth;
        pill.style.transition =
          "max-width 180ms cubic-bezier(0.22,1,0.36,1), margin 180ms cubic-bezier(0.22,1,0.36,1), padding 180ms cubic-bezier(0.22,1,0.36,1)";
        // leave the same soft way the enhance pill arrives, then drop the node
        pill.setAttribute("data-exit", "");
        pill.style.maxWidth = "0px";
        pill.style.marginLeft = "0px";
        pill.style.marginRight = "0px";
        pill.style.paddingLeft = "0px";
        pill.style.paddingRight = "0px";
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          if (sep && sep.nodeType === Node.TEXT_NODE && sep.textContent?.startsWith("\u00A0")) {
            const rest = sep.textContent.slice(1);
            if (rest) sep.textContent = rest;
            else sep.parentNode?.removeChild(sep);
          }
          pill.remove();
          syncFromEditor();
          editorRef.current?.focus();
        };
        pill.addEventListener("animationend", finish, { once: true });
        setTimeout(finish, 220);
      }
      return;
    }
    saveSelection();
  };

  // Dismiss the "+" menu on outside click / Escape.
  useEffect(() => {
    if (!menuOpen) return;
    const onDown = (e: PointerEvent) => {
      if (!plusRef.current?.contains(e.target as Node)) setMenuOpen(false);
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setMenuOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("pointerdown", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [menuOpen]);

  // Collapse the Skills flyout / model popover whenever the menu closes.
  useEffect(() => {
    if (!menuOpen) {
      setSkillsOpen(false);
      setHoveredModel(null);
    }
  }, [menuOpen]);

  // After an enhance/revert the editor is shown editable again — write the
  // pending HTML into it (enhanced text, or the restored original w/ pills).
  useLayoutEffect(() => {
    if (enhancing || pendingHTML.current === null) return;
    const editor = editorRef.current;
    if (!editor) return;
    editor.innerHTML = pendingHTML.current;
    pendingHTML.current = null;
    syncFromEditor();
    requestAnimationFrame(focusEnd);

    // Animate the frame from its previous height to the new one so the input
    // doesn't jump when the enhanced/original text changes its size.
    const frame = frameRef.current;
    const from = flipFrom.current;
    flipFrom.current = null;
    if (!frame || from === null) return;
    const to = frame.offsetHeight;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce || from === to) return;
    frame.style.height = from + "px";
    frame.style.overflow = "hidden";
    void frame.offsetHeight; // force reflow so the start height is committed
    frame.style.transition = "height 200ms cubic-bezier(0.22, 1, 0.36, 1)";
    frame.style.height = to + "px";
    let done = false;
    const finish = () => {
      if (done) return;
      done = true;
      frame.style.transition = "";
      frame.style.height = "";
      frame.style.overflow = "";
      frame.removeEventListener("transitionend", finish);
    };
    frame.addEventListener("transitionend", finish);
    setTimeout(finish, 260);
  }, [phase, enhancing]);

  // Drive the enhance pill's mount/exit. It enters when there's text; when it
  // should leave it plays the exit animation first — except when handing over
  // to the spinner (enhancing), where it swaps instantly.
  useEffect(() => {
    if (showPill) {
      setPillMounted(true);
      setPillExiting(false);
      return;
    }
    if (!pillMounted) return;
    if (enhancing) {
      setPillMounted(false);
      setPillExiting(false);
      return;
    }
    setPillExiting(true);
    const t = setTimeout(() => {
      setPillMounted(false);
      setPillExiting(false);
    }, 200);
    return () => clearTimeout(t);
  }, [showPill, enhancing, pillMounted]);

  // Cancel any in-flight enhance on unmount.
  useEffect(() => () => abortRef.current?.abort(), []);

  const runEnhance = async () => {
    if (!hasText || enhancing) return;
    preEnhanceHTML.current = editorRef.current?.innerHTML ?? "";
    setPhase("enhancing");
    const ac = new AbortController();
    abortRef.current = ac;
    try {
      const result = await onEnhance(value, ac.signal);
      if (ac.signal.aborted) return;
      pendingHTML.current = escapeHtml(result);
      flipFrom.current = frameRef.current?.offsetHeight ?? null;
      setPhase("enhanced");
    } catch {
      // Restore the untouched prompt if the call fails/aborts.
      if (ac.signal.aborted) return;
      pendingHTML.current = preEnhanceHTML.current;
      setPhase("idle");
    }
  };

  const revert = () => {
    abortRef.current?.abort();
    pendingHTML.current = preEnhanceHTML.current;
    flipFrom.current = frameRef.current?.offsetHeight ?? null;
    setPhase("idle");
  };

  const send = () => {
    if (!sendActive) return;
    const editor = editorRef.current;
    if (editor) editor.innerHTML = "";
    setValue("");
    setPhase("idle");
    setAttachments([]);
    setExitingAtt([]);
    closeSlash();
    requestAnimationFrame(() => editorRef.current?.focus());
  };

  // Play the same soft fade/scale exit as the skill pills, then drop the chip.
  const removeAttachment = (id: number) => {
    setExitingAtt((e) => (e.includes(id) ? e : [...e, id]));
    window.setTimeout(() => {
      setAttachments((a) => a.filter((x) => x.id !== id));
      setExitingAtt((e) => e.filter((x) => x !== id));
    }, 200);
  };

  const openPicker = (kind: Attachment["kind"]) => {
    const input = fileRef.current;
    if (!input) return;
    input.accept = kind === "image" ? "image/*" : "";
    input.value = "";
    input.dataset.kind = kind;
    input.click();
    setMenuOpen(false);
  };

  return (
    <div className={styles.wrap}>
      <input
        ref={fileRef}
        type="file"
        multiple
        hidden
        onChange={(e) => {
          const files = Array.from(e.target.files ?? []);
          if (!files.length) return;
          const fallback = (e.target.dataset.kind as Attachment["kind"]) ?? "file";
          setAttachments((a) => [
            ...a,
            ...files.map((f) => ({
              id: nextId.current++,
              name: f.name,
              kind: f.type.startsWith("image/") ? ("image" as const) : fallback,
            })),
          ]);
          e.target.value = "";
          requestAnimationFrame(() => editorRef.current?.focus());
        }}
      />

      <div ref={frameRef} className={styles.frame} data-enhancing={enhancing || undefined}>
        {attachments.length > 0 && (
          <div className={styles.chips}>
            {attachments.map((att) => (
              <span
                key={att.id}
                className={styles.chip}
                data-exit={exitingAtt.includes(att.id) || undefined}
              >
                <span className={styles.chipIcon}>
                  {att.kind === "image" ? <ImageIcon size={13} /> : <Paperclip size={13} />}
                </span>
                <span className={styles.chipName}>{att.name}</span>
                <button
                  type="button"
                  className={styles.chipRemove}
                  aria-label={"Remove " + att.name}
                  onClick={() => removeAttachment(att.id)}
                >
                  <X size={11} />
                </button>
              </span>
            ))}
          </div>
        )}

        <div className={styles.editorWrap}>
          {enhancing ? (
            <div className={styles.enhancingText} aria-live="polite">
              {value}
            </div>
          ) : (
            <div
              ref={editorRef}
              className={styles.field}
              contentEditable
              suppressContentEditableWarning
              role="textbox"
              aria-multiline="true"
              aria-label="Ask AI Agent"
              data-empty={!hasText || undefined}
              data-placeholder="Ask AI Agent"
              onInput={onEditorInput}
              onKeyDown={onEditorKeyDown}
              onKeyUp={saveSelection}
              onMouseUp={saveSelection}
              onBlur={saveSelection}
              onClick={onEditorClick}
            />
          )}

          {slashOpen && !enhancing && (
            <div
              className={styles.slashMenu}
              role="listbox"
              aria-label="Skills"
              data-keyboard={slashKeyboard || undefined}
              onMouseMove={() => {
                ignoreHoverRef.current = false;
                if (slashKeyboard) setSlashKeyboard(false);
              }}
            >
              <div className={styles.slashLabel}>Skills</div>
              {slashResults.length ? (
                slashResults.map((sk, i) => (
                  <button
                    key={sk.id}
                    type="button"
                    role="option"
                    aria-selected={i === slashIndex}
                    className={[styles.menuItem, i === slashIndex && styles.menuItemActive]
                      .filter(Boolean)
                      .join(" ")}
                    onMouseDown={(e) => e.preventDefault()}
                    onMouseEnter={() => {
                      if (ignoreHoverRef.current) return;
                      setSlashIndex(i);
                    }}
                    onClick={() => applySlash(sk.id)}
                  >
                    <span className={styles.menuName}>{sk.name}</span>
                  </button>
                ))
              ) : (
                <div className={styles.slashEmpty}>No matching skills</div>
              )}
            </div>
          )}
        </div>

        <div className={styles.row}>
          <div className={styles.plusWrap} ref={plusRef}>
            <button
              type="button"
              className={[styles.iconBtn, styles.plus].join(" ")}
              data-open={menuOpen || undefined}
              aria-label="Add attachment or switch model"
              aria-expanded={menuOpen}
              onClick={() => setMenuOpen((o) => !o)}
            >
              <span className={styles.plusIcon}>
                <Plus size={14} />
              </span>
            </button>

            {menuOpen && (
              <div className={styles.menu} role="menu">
                <button
                  type="button"
                  role="menuitem"
                  className={styles.menuItem}
                  onClick={() => openPicker("image")}
                >
                  <span className={styles.menuIcon}>
                    <ImageIcon size={14} />
                  </span>
                  <span className={styles.menuName}>Add photos</span>
                </button>
                <button
                  type="button"
                  role="menuitem"
                  className={styles.menuItem}
                  onClick={() => openPicker("file")}
                >
                  <span className={styles.menuIcon}>
                    <Paperclip size={14} />
                  </span>
                  <span className={styles.menuName}>Attach files</span>
                </button>
                <div className={styles.menuDivider} />
                <div
                  className={styles.menuSub}
                  onMouseEnter={() => setSkillsOpen(true)}
                  onMouseLeave={() => setSkillsOpen(false)}
                >
                  <button
                    type="button"
                    role="menuitem"
                    className={styles.menuItem}
                    aria-haspopup="menu"
                    aria-expanded={skillsOpen}
                    onClick={() => setSkillsOpen(true)}
                  >
                    <span className={styles.menuIcon}>
                      <BookOpen size={14} />
                    </span>
                    <span className={styles.menuName}>Skills</span>
                    <span className={styles.menuChevron}>
                      <ChevronRight size={14} />
                    </span>
                  </button>
                  {skillsOpen && (
                    <div className={styles.menuFlyout} role="menu">
                      {SKILLS.map((sk) => (
                        <button
                          key={sk.id}
                          type="button"
                          role="menuitem"
                          className={styles.menuItem}
                          onClick={() => addSkillFromMenu(sk.id)}
                        >
                          <span className={styles.menuName}>{sk.name}</span>
                        </button>
                      ))}
                    </div>
                  )}
                </div>
                <div className={styles.menuDivider} />
                <div className={styles.menuLabel}>Model</div>
                {MODELS.map((m) => (
                  <div
                    key={m.id}
                    className={styles.menuSub}
                    onMouseEnter={() => setHoveredModel(m.id)}
                    onMouseLeave={() => setHoveredModel(null)}
                  >
                    <button
                      type="button"
                      role="menuitemradio"
                      aria-checked={model === m.id}
                      className={styles.menuItem}
                      onClick={() => {
                        setModel(m.id);
                        setMenuOpen(false);
                      }}
                    >
                      <span className={styles.menuBrand}>
                        <ModelIcon id={m.id} />
                      </span>
                      <span className={styles.menuName}>{m.name}</span>
                      {model === m.id && (
                        <span className={styles.menuCheck}>
                          <Check size={14} />
                        </span>
                      )}
                    </button>
                    {hoveredModel === m.id && (
                      <div className={styles.menuPopover} role="tooltip">
                        <div className={styles.popoverTitle}>{m.name}</div>
                        <p className={styles.popoverDesc}>{m.desc}</p>
                        <div className={styles.popoverMeta}>{m.context}</div>
                      </div>
                    )}
                  </div>
                ))}
              </div>
            )}
          </div>

          <div className={styles.right}>
            {enhancing ? (
              <span
                className={[styles.iconBtn, styles.spinnerBtn].join(" ")}
                aria-label="Enhancing prompt"
              >
                <Loader2 size={14} className={styles.spinner} />
              </span>
            ) : (
              pillMounted && (
                <button
                  type="button"
                  className={[styles.pill, pillExiting && styles.pillExit]
                    .filter(Boolean)
                    .join(" ")}
                  onClick={phase === "enhanced" ? revert : runEnhance}
                >
                  {phase === "enhanced" ? "Revert" : "Enhance Prompt"}
                </button>
              )
            )}
            <button
              type="button"
              className={[styles.iconBtn, styles.send, sendActive && styles.sendActive]
                .filter(Boolean)
                .join(" ")}
              aria-label="Send"
              disabled={!sendActive}
              onClick={send}
            >
              <ArrowUp size={14} />
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}
```

### React — `PromptInput.module.css`

```css
.wrap {
  width: 100%;
  max-width: 420px;
  font-family: "Inter Variable", "Inter", sans-serif;
}

.frame {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 8px 10px 10px;
  background: #ffffff;
  /* transparent border keeps the box geometry the enhancing ::after relies on;
     the visible 0.5px hairline + drop shadow match the surrounding cards. */
  border: 0.5px solid transparent;
  border-radius: 12px;
  /* hairline ring first so it paints on top of the drops and stays even on
     every edge (otherwise the bottom is hidden by the drop shadow) */
  box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05),
    0 2px 4px rgba(0, 0, 0, 0.02);
}
/* with chips present, match the 10px side padding on top */
.frame:has(.chips) {
  padding-top: 10px;
}

/* enhancing: a conic-gradient ring sweeps around the border */
@property --pi-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.frame[data-enhancing] {
  border-color: transparent;
}
.frame[data-enhancing]::after {
  content: "";
  position: absolute;
  inset: -0.5px;
  border-radius: 12.5px;
  /* border + padding-box mask keeps the ring an even 0.75px on every side —
     the older content-box/padding trick rendered the bottom edge thinner */
  border: 0.75px solid transparent;
  background: conic-gradient(
      from var(--pi-angle),
      #2b7fff, #8b5cf6, #d946ef, #22d3ee, #2b7fff
    )
    border-box;
  -webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  animation: pi-border-spin 1.1s linear infinite,
    pi-border-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}
@keyframes pi-border-spin {
  to { --pi-angle: 360deg; }
}
@keyframes pi-border-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* editable field — a contentEditable div so skill pills can flow inline */
.editorWrap {
  position: relative;
}
.field {
  position: relative;
  width: 100%;
  margin: 0;
  outline: 0;
  background: transparent;
  color: #1a1a1a;
  font: inherit;
  font-size: 12px;
  line-height: 18px;
  letter-spacing: -0.12px;
  min-height: 18px;
  max-height: 160px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
.field ::selection,
.field::selection {
  background: Highlight;
  color: HighlightText;
}
.field ::-moz-selection,
.field::-moz-selection {
  background: Highlight;
  color: HighlightText;
}
.field[data-empty]::before {
  content: attr(data-placeholder);
  position: absolute;
  top: 0;
  left: 0;
  color: #1a1a1a;
  opacity: 0.5;
  pointer-events: none;
}

/* inline skill pill — Enhance-Prompt dimensions + a blue tint and an × */
.skillPill {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  /* shorter than the field's 18px line-height so the pill never expands the
     line box (middle + 18px height grew the field and jumped the action gap) */
  height: 16px;
  padding: 0 0 0 5px;
  margin: 0 2px;
  border-radius: 999px;
  background: rgba(43, 127, 255, 0.12);
  color: #1f6feb;
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.12px;
  white-space: nowrap;
  position: relative;
  top: -1px;
  vertical-align: baseline;
  user-select: none;
}
/* at the very start of the field: drop the left margin, keep only the right
   (data-start is set in JS since :first-child ignores leading text nodes) */
.skillPill[data-start] {
  margin-left: 0;
}
.skillPillLabel {
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
}
.skillPillX {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: inherit;
  opacity: 0.65;
  cursor: pointer;
  transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1),
    background 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.skillPillX:hover {
  opacity: 1;
  background: rgba(43, 127, 255, 0.16);
}
/* leave the same soft way the enhance pill arrives */
.skillPill[data-exit] {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}

/* "/" command palette — same container as the + menu, pinned above the field */
.slashMenu {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 25;
  width: 200px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  transform-origin: bottom left;
  animation: pi-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.slashLabel {
  padding: 3px 7px;
  font-size: 11px;
  font-weight: 425;
  color: #a1a1a1;
}
.slashEmpty {
  padding: 6px 7px;
  font-size: 11px;
  color: #a1a1a1;
}

/* enhancing: the current text shimmers while the model rewrites it */
.enhancingText {
  font-size: 12px;
  line-height: 18px;
  letter-spacing: -0.12px;
  word-break: break-word;
  color: transparent;
  -webkit-text-fill-color: transparent;
  background: linear-gradient(
    90deg,
    #1a1a1a 0%, #1a1a1a 30%,
    rgba(26, 26, 26, 0.45) 45%, rgba(26, 26, 26, 0.45) 55%,
    #1a1a1a 70%, #1a1a1a 100%
  );
  background-size: 300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  animation: pi-shine 2.25s cubic-bezier(0.25, 0.1, 0.25, 1) infinite;
}
@keyframes pi-shine {
  0%, 18% { background-position: 100% 0; }
  82%, 100% { background-position: 0% 0; }
}

/* attachment chips — same white surface + hairline outline as the frame */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  /* tighten only the chips->text gap (frame gap is 12px) without touching the
     text->button-row gap */
  margin-bottom: -6px;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  padding: 3px 4px 3px 5px;
  border-radius: 999px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
  color: #1a1a1a;
  font-size: 11px;
  line-height: 14px;
  animation: pi-chip-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* leave the same soft fade/scale way the skill pills do */
.chip[data-exit] {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}
.chipIcon {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
.chipName {
  max-width: 150px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chipRemove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* pull 2px closer to the filename without changing the icon->text gap */
  margin-left: -2px;
  width: 15px;
  height: 15px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: #a1a1a1;
  cursor: pointer;
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.chipRemove:hover {
  background: rgba(26, 26, 26, 0.08);
  color: #1a1a1a;
}
@keyframes pi-chip-in {
  from { opacity: 0; transform: translateY(4px); filter: blur(2px); }
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
}

.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.plusWrap {
  position: relative;
  display: flex;
}
.right {
  display: flex;
  align-items: center;
  gap: 6px;
}

/* round soft button.
   The fill lives on ::before so a press scales only the container (0.98),
   never the icon on top. */
.iconBtn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  flex: none;
  border: 0;
  background: transparent;
  color: #1a1a1a;
  cursor: pointer;
}
.iconBtn::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: rgba(26, 26, 26, 0.06);
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.iconBtn:hover::before {
  background: rgba(26, 26, 26, 0.1);
}
.iconBtn:active::before {
  transform: scale(0.98);
}
/* keep the icon above the (opaque, when active) ::before fill */
.iconBtn > svg {
  position: relative;
}

.plusIcon {
  position: relative;
  display: inline-flex;
  transition: transform 200ms cubic-bezier(0.35, 1.55, 0.65, 1);
}
.plus[data-open]::before {
  background: rgba(26, 26, 26, 0.12);
}
.plus[data-open] .plusIcon {
  transform: rotate(45deg);
}

/* enhance / revert pill — same fill + press behaviour as the icon buttons */
.pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 8px;
  border: 0;
  background: transparent;
  color: #1a1a1a;
  font-size: 11px;
  line-height: 12px;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  animation: pi-pill-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.pill::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: rgba(26, 26, 26, 0.06);
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.pill:hover::before {
  background: rgba(26, 26, 26, 0.1);
}
.pill:active::before {
  transform: scale(0.98);
}
@keyframes pi-pill-in {
  from { opacity: 0; transform: scale(0.96); filter: blur(2px); }
  to { opacity: 1; transform: scale(1); filter: blur(0); }
}
/* symmetric exit — mirrors pi-pill-in so the enhance pill (and the inline
   skill pills) leave the same soft way they arrive */
@keyframes pi-pill-out {
  from { opacity: 1; transform: scale(1); filter: blur(0); }
  to { opacity: 0; transform: scale(0.96); filter: blur(2px); }
}
.pill.pillExit {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}

.send {
  color: #a1a1a1;
}
.send:disabled {
  cursor: default;
}
.send:disabled:active::before {
  transform: none;
}
.sendActive {
  color: #ffffff;
}
.sendActive::before {
  background: #0b0d12;
}
.sendActive:hover::before {
  background: #2a2f3a;
}

.spinnerBtn {
  cursor: default;
}
.spinner {
  position: relative;
  color: #a1a1a1;
  animation: pi-spin 0.7s linear infinite;
}
@keyframes pi-spin {
  to { transform: rotate(360deg); }
}

/* "+" menu — same container style as the input frame */
.menu {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 0;
  z-index: 20;
  width: 180px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  transform-origin: bottom left;
  animation: pi-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.menuItem {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  height: 26px;
  padding: 0 7px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: #1a1a1a;
  font-size: 11px;
  font-weight: 425;
  line-height: 12px;
  text-align: left;
  cursor: pointer;
}
.menuItem:hover {
  background: rgba(26, 26, 26, 0.06);
}
.menuItem:active {
  background: rgba(26, 26, 26, 0.09);
}
.menuItem.menuItemActive {
  background: rgba(26, 26, 26, 0.06);
}
.slashMenu[data-keyboard] .menuItem:hover {
  background: transparent;
}
.slashMenu[data-keyboard] .menuItem.menuItemActive,
.slashMenu[data-keyboard] .menuItem.menuItemActive:hover {
  background: rgba(26, 26, 26, 0.06);
}
.wrap svg {
  stroke-width: 1.5px;
}
.menuIcon {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
.menuName {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.menuCheck {
  display: inline-flex;
  flex: none;
  color: #1a1a1a;
}

/* Skills — a side flyout that expands from the "Skills" row */
.menuSub {
  position: relative;
}
.menuChevron {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
/* brand marks keep their own colours; ChatGPT is monochrome so it follows text */
.menuBrand {
  display: inline-flex;
  flex: none;
  color: #1a1a1a;
}
.menuFlyout {
  position: absolute;
  top: -3px;
  left: calc(100% + 6px);
  width: 168px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
}
/* invisible bridge across the 6px gap so the hover doesn't drop when the
   pointer travels from the row into the flyout */
.menuFlyout::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -7px;
  width: 7px;
}

/* model info popover — a non-interactive card shown on hover to the right */
.menuPopover {
  position: absolute;
  top: -3px;
  left: calc(100% + 6px);
  z-index: 30;
  width: 200px;
  padding: 10px 12px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  pointer-events: none;
}
.popoverTitle {
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: #1a1a1a;
}
.popoverDesc {
  margin: 2px 0 0;
  font-size: 11px;
  line-height: 15px;
  color: #a1a1a1;
}
.popoverMeta {
  margin-top: 8px;
  font-size: 11px;
  line-height: 14px;
  color: #a1a1a1;
}
.menuDivider {
  height: 0.5px;
  margin: 4px -3px;
  background: #e6e8ec;
}
.menuLabel {
  padding: 3px 7px;
  font-size: 11px;
  font-weight: 425;
  color: #a1a1a1;
}
@keyframes pi-menu-in {
  from { opacity: 0; transform: translateY(6px) scale(0.98); filter: blur(2px); }
  to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}

@media (prefers-color-scheme: dark) {
  .frame {
    background: #1a1a1a;
    box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.12),
      0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
  }
  .frame[data-enhancing]::after {
    background: conic-gradient(
        from var(--pi-angle),
        #3b6fb5, #6b5aa6, #9a4f96, #3a8a9a, #3b6fb5
      )
      border-box;
  }
  .field { color: #f5f5f5; }
  .field::placeholder { color: #f5f5f5; }
  .enhancingText {
    background: linear-gradient(
      90deg,
      #f5f5f5 0%, #f5f5f5 30%,
      rgba(245, 245, 245, 0.45) 45%, rgba(245, 245, 245, 0.45) 55%,
      #f5f5f5 70%, #f5f5f5 100%
    );
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
  }
  .chip { background: #1a1a1a; border-color: #303030; color: #f5f5f5; }
  .chipIcon { color: #a3a3a3; }
  .chipRemove { color: #a3a3a3; }
  .chipRemove:hover { background: rgba(245, 245, 245, 0.08); color: #f5f5f5; }
  .iconBtn { color: #f5f5f5; }
  .iconBtn::before { background: rgba(245, 245, 245, 0.06); }
  .iconBtn:hover::before { background: rgba(245, 245, 245, 0.1); }
  .plus[data-open]::before { background: rgba(245, 245, 245, 0.12); }
  .pill { color: #f5f5f5; }
  .pill::before { background: rgba(245, 245, 245, 0.06); }
  .pill:hover::before { background: rgba(245, 245, 245, 0.1); }
  .sendActive { color: #0a0a0a; }
  .sendActive::before { background: #f5f5f5; }
  .sendActive:hover::before { background: #ffffff; }
  .spinner { color: #a3a3a3; }
  .menu { background: #1a1a1a; border-color: #303030; }
  .menuItem { color: #f5f5f5; }
  .menuItem:hover { background: rgba(245, 245, 245, 0.06); }
  .menuItem:active { background: rgba(245, 245, 245, 0.09); }
  .menuIcon { color: #a3a3a3; }
  .menuCheck { color: #f5f5f5; }
  .menuChevron { color: #a3a3a3; }
  .menuBrand { color: #f5f5f5; }
  .menuDivider { background: #303030; }
  .menuLabel { color: #a3a3a3; }
  .menuFlyout { background: #1a1a1a; border-color: #303030; }
  .menuPopover { background: #1a1a1a; border-color: #303030; }
  .popoverTitle { color: #f5f5f5; }
  .popoverDesc, .popoverMeta { color: #a3a3a3; }
  .skillPill { background: rgba(43, 127, 255, 0.22); color: #9ec5ff; }
  .slashMenu { background: #1a1a1a; border-color: #303030; }
  .slashLabel, .slashEmpty { color: #a3a3a3; }
  .menuItem.menuItemActive { background: rgba(245, 245, 245, 0.06); }
  .slashMenu[data-keyboard] .menuItem.menuItemActive,
  .slashMenu[data-keyboard] .menuItem.menuItemActive:hover {
    background: rgba(245, 245, 245, 0.06);
  }
}

@media (prefers-reduced-motion: reduce) {
  .iconBtn::before, .pill::before, .menuItem, .chipRemove, .plusIcon { transition: none; }
  .chip, .chip[data-exit], .pill, .pill.pillExit, .skillPill[data-exit], .menu, .menuFlyout, .menuPopover, .slashMenu { animation: none; }
  .enhancingText, .frame[data-enhancing]::after { animation: none; }
  .spinner { animation-duration: 1.4s; }
}
```

### Vue — `PromptInput.vue`

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, nextTick, ref, watch } from "vue";
import {
  ArrowUp,
  BookOpen,
  Check,
  ChevronRight,
  Image as ImageIcon,
  Loader2,
  Paperclip,
  Plus,
  X,
} from "lucide-vue-next";

const ENHANCED =
  "This is an example prompt — rewritten to be clear and specific: state the goal, add the relevant context and constraints, define the expected output format and tone, and note any assumptions. Ask a clarifying question first if key details are missing.";

/**
 * Integration seam: replace the mock body with a real request to your
 * model/API. It only needs to resolve to the enhanced prompt string.
 */
async function mockEnhance(prompt: string, signal?: AbortSignal): Promise<string> {
  await new Promise((r) => setTimeout(r, 2500));
  if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
  return ENHANCED;
  // const res = await fetch("/api/enhance", {
  //   method: "POST",
  //   headers: { "Content-Type": "application/json" },
  //   body: JSON.stringify({ prompt }),
  //   signal,
  // });
  // if (!res.ok) throw new Error("Enhance request failed");
  // return (await res.json()).prompt as string;
}

const props = withDefaults(
  defineProps<{ onEnhance?: (prompt: string, signal?: AbortSignal) => Promise<string> }>(),
  { onEnhance: mockEnhance },
);

const MODELS = [
  {
    id: "claude-opus-4.8",
    name: "Claude Opus 4.8",
    desc: "Anthropic's most capable model — best for complex, multi-step reasoning.",
    context: "200k context window",
  },
  {
    id: "gpt-5.6",
    name: "GPT-5.6",
    desc: "OpenAI's flagship — strong all-round performance and tool use.",
    context: "400k context window",
  },
  {
    id: "gemini-2.5-pro",
    name: "Gemini 2.5 Pro",
    desc: "Google's long-context model — great for large documents and codebases.",
    context: "1M context window",
  },
];

const SKILLS = [
  { id: "deep-research", name: "Deep Research" },
  { id: "code-review", name: "Code Review" },
  { id: "web-search", name: "Web Search" },
  { id: "summarize", name: "Summarize" },
];

const skillName = (id: string) => SKILLS.find((sk) => sk.id === id)?.name ?? id;
const escapeHtml = (str: string) =>
  str.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] ?? c));

type Phase = "idle" | "enhancing" | "enhanced";
type Attachment = { id: number; name: string; kind: "image" | "file" };

const value = ref("");
const phase = ref<Phase>("idle");
const menuOpen = ref(false);
const skillsOpen = ref(false);
const hoveredModel = ref<string | null>(null);
const model = ref(MODELS[0].id);
const attachments = ref<Attachment[]>([]);
// ids of chips currently playing their exit animation before removal
const exitingAtt = ref<number[]>([]);

// Keep the enhance pill mounted through a short exit so it leaves the same
// soft way it arrives (mirrors pi-pill-in / pi-pill-out).
const pillMounted = ref(false);
const pillExiting = ref(false);
let pillTimer: ReturnType<typeof setTimeout> | null = null;

const slashOpen = ref(false);
const slashQuery = ref("");
const slashIndex = ref(0);
const slashKeyboard = ref(false);
let lastSlashQuery = "";
let ignoreHover = false;

const editor = ref<HTMLElement | null>(null);
const frame = ref<HTMLElement | null>(null);
const plusWrap = ref<HTMLElement | null>(null);
const fileRef = ref<HTMLInputElement | null>(null);
let preEnhanceHTML = "";
let pendingHTML: string | null = null;
// height of the frame captured right before an enhance/revert swap, so the
// new height can be animated from it (FLIP) instead of jumping.
let flipFrom: number | null = null;
let savedRange: Range | null = null;
let abort: AbortController | null = null;
let nextId = 1;

const hasText = computed(() => value.value.trim().length > 0);
const enhancing = computed(() => phase.value === "enhancing");
const sendActive = computed(() => hasText.value && !enhancing.value);
const showPill = computed(() => hasText.value && !enhancing.value);
const slashResults = computed(() =>
  SKILLS.filter((sk) => sk.name.toLowerCase().includes(slashQuery.value.toLowerCase())),
);

// Drive the enhance pill's mount/exit — enter with text, play the exit first
// when leaving, but swap instantly when handing over to the spinner.
watch(
  showPill,
  (show) => {
    if (show) {
      pillMounted.value = true;
      pillExiting.value = false;
      if (pillTimer) {
        clearTimeout(pillTimer);
        pillTimer = null;
      }
      return;
    }
    if (!pillMounted.value) return;
    if (enhancing.value) {
      pillMounted.value = false;
      pillExiting.value = false;
      return;
    }
    pillExiting.value = true;
    if (pillTimer) clearTimeout(pillTimer);
    pillTimer = setTimeout(() => {
      pillMounted.value = false;
      pillExiting.value = false;
      pillTimer = null;
    }, 200);
  },
  { immediate: true },
);

// Focus the editor and drop the caret at the very end of its content.
function focusEnd() {
  const el = editor.value;
  if (!el) return;
  el.focus();
  const range = document.createRange();
  range.selectNodeContents(el);
  range.collapse(false);
  const sel = window.getSelection();
  sel?.removeAllRanges();
  sel?.addRange(range);
  savedRange = range.cloneRange();
}
function syncFromEditor() {
  const el = editor.value;
  if (!el) return;
  value.value = el.textContent ?? "";
  // Mark pills at the very start (nothing but whitespace before them) so CSS can
  // drop their left margin — :first-child can't see leading text nodes.
  el.querySelectorAll<HTMLElement>(".skill-pill").forEach((pill) => {
    let atStart = true;
    for (let n = pill.previousSibling; n; n = n.previousSibling) {
      if (n.nodeType === Node.TEXT_NODE && (n.textContent ?? "").trim() === "") continue;
      atStart = false;
      break;
    }
    pill.toggleAttribute("data-start", atStart);
  });
}
function saveSelection() {
  const sel = window.getSelection();
  if (sel && sel.rangeCount && editor.value && editor.value.contains(sel.anchorNode)) {
    savedRange = sel.getRangeAt(0).cloneRange();
  }
}
function closeSlash() {
  slashOpen.value = false;
  slashQuery.value = "";
  slashIndex.value = 0;
  slashKeyboard.value = false;
  lastSlashQuery = "";
  ignoreHover = false;
}
function buildPill(id: string) {
  const name = skillName(id);
  const el = document.createElement("span");
  el.className = "skill-pill";
  el.setAttribute("contenteditable", "false");
  el.dataset.skill = id;
  el.innerHTML =
    '<span class="skill-pill-label">/' + escapeHtml(name) + "</span>" +
    '<button type="button" class="skill-pill-x" data-remove="1" aria-label="Remove ' +
    escapeHtml(name) + '"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
  return el;
}
function insertPillOverRange(range: Range, id: string) {
  if (!editor.value) return;
  range.deleteContents();
  const pill = buildPill(id);
  range.insertNode(pill);
  const space = document.createTextNode("\u00A0");
  pill.after(space);
  const after = document.createRange();
  after.setStartAfter(space);
  after.collapse(true);
  const sel = window.getSelection();
  sel?.removeAllRanges();
  sel?.addRange(after);
  editor.value.focus();
  savedRange = after.cloneRange();
  syncFromEditor();
}
function addSkillFromMenu(id: string) {
  const el = editor.value;
  if (!el) return;
  const sel = window.getSelection();
  let range: Range | null = null;
  if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
    range = sel.getRangeAt(0).cloneRange();
  } else if (savedRange && el.contains(savedRange.startContainer)) {
    range = savedRange.cloneRange();
  }
  if (!range) {
    range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
  }
  insertPillOverRange(range, id);
  menuOpen.value = false;
}
function applySlash(id: string) {
  const el = editor.value;
  const sel = window.getSelection();
  if (!el || !sel || !sel.rangeCount) return closeSlash();
  const caret = sel.getRangeAt(0);
  let range = caret.cloneRange();
  const node = caret.startContainer;
  if (node.nodeType === Node.TEXT_NODE && el.contains(node)) {
    const before = (node.textContent ?? "").slice(0, caret.startOffset);
    const m = before.match(/\/([^\s/]*)$/);
    if (m) {
      range = document.createRange();
      range.setStart(node, caret.startOffset - m[0].length);
      range.setEnd(node, caret.startOffset);
    }
  }
  insertPillOverRange(range, id);
  closeSlash();
}
function detectSlash() {
  const el = editor.value;
  const sel = window.getSelection();
  if (!el || !sel || !sel.rangeCount || !sel.isCollapsed) return closeSlash();
  const range = sel.getRangeAt(0);
  const node = range.startContainer;
  if (node.nodeType !== Node.TEXT_NODE || !el.contains(node)) return closeSlash();
  const before = (node.textContent ?? "").slice(0, range.startOffset);
  const m = before.match(/(?:^|\s)\/([^\s/]*)$/);
  if (!m) return closeSlash();
  const q = m[1];
  if (q !== lastSlashQuery) {
    lastSlashQuery = q;
    slashIndex.value = 0;
  }
  slashQuery.value = q;
  slashOpen.value = true;
}
function onEditorInput() {
  syncFromEditor();
  if (phase.value === "enhanced") phase.value = "idle";
  detectSlash();
}
function moveSlash(delta: number) {
  const results = slashResults.value;
  if (!results.length) return;
  ignoreHover = true;
  slashKeyboard.value = true;
  slashIndex.value = (slashIndex.value + delta + results.length * 10) % results.length;
}
function onSlashMouseEnter(i: number) {
  if (ignoreHover) return;
  slashIndex.value = i;
}
function onSlashMouseMove() {
  ignoreHover = false;
  slashKeyboard.value = false;
}
function onEditorKeydown(e: KeyboardEvent) {
  const results = slashResults.value;
  if (
    slashOpen.value &&
    results.length &&
    (e.key === "ArrowDown" ||
      e.key === "ArrowUp" ||
      e.key === "Enter" ||
      e.key === "Tab" ||
      e.key === "Escape")
  ) {
    e.preventDefault();
    return;
  }
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    send();
  }
}
watch(slashOpen, (open) => {
  if (!open) return;
  const onKey = (e: KeyboardEvent) => {
    const results = slashResults.value;
    if (!slashOpen.value || !results.length) return;
    if (e.key === "ArrowDown") {
      e.preventDefault();
      e.stopPropagation();
      moveSlash(1);
      return;
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      e.stopPropagation();
      moveSlash(-1);
      return;
    }
    if (e.key === "Enter" || e.key === "Tab") {
      e.preventDefault();
      e.stopPropagation();
      applySlash((results[slashIndex.value] ?? results[0]).id);
      return;
    }
    if (e.key === "Escape") {
      e.preventDefault();
      e.stopPropagation();
      closeSlash();
    }
  };
  window.addEventListener("keydown", onKey, true);
  return () => window.removeEventListener("keydown", onKey, true);
});
watch(slashResults, (results) => {
  if (slashOpen.value && results.length && slashIndex.value >= results.length) {
    slashIndex.value = 0;
  }
});
function onEditorClick(e: MouseEvent) {
  const remove = (e.target as HTMLElement).closest("[data-remove]");
  if (remove) {
    e.preventDefault();
    const pill = remove.closest<HTMLElement>("[data-skill]");
    if (pill) {
      // the separator space we inserted right after the pill — drop it too on
      // removal so leftover spaces can't accumulate and shift the next pill.
      const sep = pill.nextSibling;
      // collapse the pill's footprint (width + margins + padding) in sync with
      // the fade so following text slides in smoothly instead of snapping.
      const w = pill.getBoundingClientRect().width;
      pill.style.maxWidth = `${w}px`;
      pill.style.overflow = "hidden";
      pill.style.whiteSpace = "nowrap";
      void pill.offsetWidth;
      pill.style.transition =
        "opacity 180ms cubic-bezier(0.22,1,0.36,1), transform 180ms cubic-bezier(0.22,1,0.36,1), filter 180ms cubic-bezier(0.22,1,0.36,1), max-width 180ms cubic-bezier(0.22,1,0.36,1), margin 180ms cubic-bezier(0.22,1,0.36,1), padding 180ms cubic-bezier(0.22,1,0.36,1)";
      // leave the same soft way the enhance pill arrives, then drop the node
      pill.setAttribute("data-exit", "");
      pill.style.maxWidth = "0px";
      pill.style.marginLeft = "0px";
      pill.style.marginRight = "0px";
      pill.style.paddingLeft = "0px";
      pill.style.paddingRight = "0px";
      let done = false;
      const finish = () => {
        if (done) return;
        done = true;
        if (sep && sep.nodeType === Node.TEXT_NODE && sep.textContent?.startsWith("\u00A0")) {
          const rest = sep.textContent.slice(1);
          if (rest) sep.textContent = rest;
          else sep.parentNode?.removeChild(sep);
        }
        pill.remove();
        syncFromEditor();
        editor.value?.focus();
      };
      pill.addEventListener("transitionend", finish, { once: true });
      setTimeout(finish, 220);
    }
    return;
  }
  saveSelection();
}

async function enhance() {
  if (!hasText.value || enhancing.value) return;
  preEnhanceHTML = editor.value?.innerHTML ?? "";
  phase.value = "enhancing";
  const ac = new AbortController();
  abort = ac;
  try {
    const result = await props.onEnhance(value.value, ac.signal);
    if (ac.signal.aborted) return;
    pendingHTML = escapeHtml(result);
    flipFrom = frame.value?.offsetHeight ?? null;
    phase.value = "enhanced";
  } catch {
    if (ac.signal.aborted) return;
    pendingHTML = preEnhanceHTML;
    phase.value = "idle";
  }
}
function revert() {
  abort?.abort();
  pendingHTML = preEnhanceHTML;
  flipFrom = frame.value?.offsetHeight ?? null;
  phase.value = "idle";
}
function send() {
  if (!sendActive.value) return;
  if (editor.value) editor.value.innerHTML = "";
  value.value = "";
  phase.value = "idle";
  attachments.value = [];
  exitingAtt.value = [];
  closeSlash();
  nextTick(() => editor.value?.focus());
}

// After an enhance/revert the editor is shown editable again — write the
// pending HTML into it (enhanced text, or the restored original w/ pills).
watch(phase, async () => {
  if (enhancing.value || pendingHTML === null) return;
  await nextTick();
  if (!editor.value) return;
  editor.value.innerHTML = pendingHTML;
  pendingHTML = null;
  syncFromEditor();
  requestAnimationFrame(focusEnd);

  // Animate the frame from its previous height to the new one so the input
  // doesn't jump when the enhanced/original text changes its size.
  const el = frame.value;
  const from = flipFrom;
  flipFrom = null;
  if (!el || from === null) return;
  const to = el.offsetHeight;
  const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduce || from === to) return;
  el.style.height = from + "px";
  el.style.overflow = "hidden";
  void el.offsetHeight; // force reflow so the start height is committed
  el.style.transition = "height 200ms cubic-bezier(0.22, 1, 0.36, 1)";
  el.style.height = to + "px";
  let done = false;
  const finish = () => {
    if (done) return;
    done = true;
    el.style.transition = "";
    el.style.height = "";
    el.style.overflow = "";
    el.removeEventListener("transitionend", finish);
  };
  el.addEventListener("transitionend", finish);
  setTimeout(finish, 260);
});

function openPicker(kind: Attachment["kind"]) {
  const input = fileRef.value;
  if (!input) return;
  input.accept = kind === "image" ? "image/*" : "";
  input.value = "";
  input.dataset.kind = kind;
  input.click();
  menuOpen.value = false;
}
function onFiles(e: Event) {
  const input = e.target as HTMLInputElement;
  const files = Array.from(input.files ?? []);
  if (!files.length) return;
  const fallback = (input.dataset.kind as Attachment["kind"]) ?? "file";
  attachments.value = [
    ...attachments.value,
    ...files.map((f) => ({
      id: nextId++,
      name: f.name,
      kind: f.type.startsWith("image/") ? ("image" as const) : fallback,
    })),
  ];
  input.value = "";
  nextTick(() => editor.value?.focus());
}
function removeAttachment(id: number) {
  // play the same soft fade/scale exit as the skill pills, then drop the chip
  if (!exitingAtt.value.includes(id)) exitingAtt.value = [...exitingAtt.value, id];
  window.setTimeout(() => {
    attachments.value = attachments.value.filter((a) => a.id !== id);
    exitingAtt.value = exitingAtt.value.filter((x) => x !== id);
  }, 200);
}
function selectModel(id: string) {
  model.value = id;
  menuOpen.value = false;
}

function onDocDown(e: PointerEvent) {
  if (menuOpen.value && plusWrap.value && !plusWrap.value.contains(e.target as Node))
    menuOpen.value = false;
}
function onDocKey(e: KeyboardEvent) {
  if (e.key === "Escape") menuOpen.value = false;
}
watch(menuOpen, (open) => {
  if (open) {
    document.addEventListener("pointerdown", onDocDown);
    document.addEventListener("keydown", onDocKey);
  } else {
    skillsOpen.value = false;
    hoveredModel.value = null;
    document.removeEventListener("pointerdown", onDocDown);
    document.removeEventListener("keydown", onDocKey);
  }
});
onBeforeUnmount(() => {
  abort?.abort();
  if (pillTimer) clearTimeout(pillTimer);
  document.removeEventListener("pointerdown", onDocDown);
  document.removeEventListener("keydown", onDocKey);
});
</script>

<template>
  <div class="wrap">
    <input
      ref="fileRef"
      type="file"
      multiple
      hidden
      @change="onFiles"
    />
    <div ref="frame" class="frame" :data-enhancing="enhancing || undefined">
      <div v-if="attachments.length" class="chips">
        <span
          v-for="att in attachments"
          :key="att.id"
          class="chip"
          :data-exit="exitingAtt.includes(att.id) || undefined"
        >
          <span class="chip-icon">
            <ImageIcon v-if="att.kind === 'image'" :size="13" />
            <Paperclip v-else :size="13" />
          </span>
          <span class="chip-name">{{ att.name }}</span>
          <button
            type="button"
            class="chip-remove"
            :aria-label="'Remove ' + att.name"
            @click="removeAttachment(att.id)"
          >
            <X :size="11" />
          </button>
        </span>
      </div>

      <div class="editor-wrap">
        <div v-if="enhancing" class="enhancing-text" aria-live="polite">
          {{ value }}
        </div>
        <div
          v-else
          ref="editor"
          class="field"
          contenteditable="true"
          role="textbox"
          aria-multiline="true"
          aria-label="Ask AI Agent"
          :data-empty="!hasText || undefined"
          data-placeholder="Ask AI Agent"
          @input="onEditorInput"
          @keydown="onEditorKeydown"
          @keyup="saveSelection"
          @mouseup="saveSelection"
          @blur="saveSelection"
          @click="onEditorClick"
        ></div>

        <div
          v-if="slashOpen && !enhancing"
          class="slash-menu"
          role="listbox"
          aria-label="Skills"
          :data-keyboard="slashKeyboard || undefined"
          @mousemove="onSlashMouseMove"
        >
          <div class="slash-label">Skills</div>
          <template v-if="slashResults.length">
            <button
              v-for="(sk, i) in slashResults"
              :key="sk.id"
              type="button"
              role="option"
              :aria-selected="i === slashIndex"
              class="menu-item"
              :class="{ 'menu-item-active': i === slashIndex }"
              @mousedown.prevent
              @mouseenter="onSlashMouseEnter(i)"
              @click="applySlash(sk.id)"
            >
              <span class="menu-name">{{ sk.name }}</span>
            </button>
          </template>
          <div v-else class="slash-empty">No matching skills</div>
        </div>
      </div>

      <div class="row">
        <div class="plus-wrap" ref="plusWrap">
          <button
            type="button"
            class="icon-btn plus"
            :data-open="menuOpen || undefined"
            aria-label="Add attachment or switch model"
            :aria-expanded="menuOpen"
            @click="menuOpen = !menuOpen"
          >
            <span class="plus-icon"><Plus :size="14" /></span>
          </button>

          <div v-if="menuOpen" class="menu" role="menu">
            <button type="button" role="menuitem" class="menu-item" @click="openPicker('image')">
              <span class="menu-icon"><ImageIcon :size="14" /></span>
              <span class="menu-name">Add photos</span>
            </button>
            <button type="button" role="menuitem" class="menu-item" @click="openPicker('file')">
              <span class="menu-icon"><Paperclip :size="14" /></span>
              <span class="menu-name">Attach files</span>
            </button>
            <div class="menu-divider"></div>
            <div
              class="menu-sub"
              @mouseenter="skillsOpen = true"
              @mouseleave="skillsOpen = false"
            >
              <button
                type="button"
                role="menuitem"
                class="menu-item"
                aria-haspopup="menu"
                :aria-expanded="skillsOpen"
                @click="skillsOpen = true"
              >
                <span class="menu-icon"><BookOpen :size="14" /></span>
                <span class="menu-name">Skills</span>
                <span class="menu-chevron"><ChevronRight :size="14" /></span>
              </button>
              <div v-if="skillsOpen" class="menu-flyout" role="menu">
                <button
                  v-for="sk in SKILLS"
                  :key="sk.id"
                  type="button"
                  role="menuitem"
                  class="menu-item"
                  @click="addSkillFromMenu(sk.id)"
                >
                  <span class="menu-name">{{ sk.name }}</span>
                </button>
              </div>
            </div>
            <div class="menu-divider"></div>
            <div class="menu-label">Model</div>
            <div
              v-for="m in MODELS"
              :key="m.id"
              class="menu-sub"
              @mouseenter="hoveredModel = m.id"
              @mouseleave="hoveredModel = null"
            >
              <button
                type="button"
                role="menuitemradio"
                :aria-checked="model === m.id"
                class="menu-item"
                @click="selectModel(m.id)"
              >
                <span class="menu-brand">
                  <svg v-if="m.id.startsWith('gpt')" width="12" height="12" viewBox="0 0 320 320" fill="currentColor" aria-hidden="true">
                    <path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
                  </svg>
                  <svg v-else-if="m.id.startsWith('claude')" width="12" height="12" viewBox="0 0 100 100" fill="#d97757" aria-hidden="true">
                    <path d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z" />
                  </svg>
                  <svg v-else width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
                    <path d="M16 8.016A8.522 8.522 0 0 0 8.016 16h-.032A8.521 8.521 0 0 0 0 8.016v-.032A8.521 8.521 0 0 0 7.984 0h.032A8.522 8.522 0 0 0 16 7.984v.032z" fill="url(#pi-gemini-grad)" />
                    <defs>
                      <radialGradient id="pi-gemini-grad" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)">
                        <stop offset=".067" stop-color="#9168C0" />
                        <stop offset=".343" stop-color="#5684D1" />
                        <stop offset=".672" stop-color="#1BA1E3" />
                      </radialGradient>
                    </defs>
                  </svg>
                </span>
                <span class="menu-name">{{ m.name }}</span>
                <span v-if="model === m.id" class="menu-check"><Check :size="14" /></span>
              </button>
              <div v-if="hoveredModel === m.id" class="menu-popover" role="tooltip">
                <div class="popover-title">{{ m.name }}</div>
                <p class="popover-desc">{{ m.desc }}</p>
                <div class="popover-meta">{{ m.context }}</div>
              </div>
            </div>
          </div>
        </div>

        <div class="right">
          <span v-if="enhancing" class="icon-btn spinner-btn" aria-label="Enhancing prompt">
            <Loader2 class="spinner" :size="14" />
          </span>
          <button
            v-else-if="pillMounted"
            type="button"
            class="pill"
            :class="{ 'pill-exit': pillExiting }"
            @click="phase === 'enhanced' ? revert() : enhance()"
          >
            {{ phase === "enhanced" ? "Revert" : "Enhance Prompt" }}
          </button>
          <button
            type="button"
            class="icon-btn send"
            :class="{ 'send-active': sendActive }"
            aria-label="Send"
            :disabled="!sendActive"
            @click="send"
          >
            <ArrowUp :size="14" />
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.wrap {
  width: 100%;
  max-width: 420px;
  font-family: "Inter Variable", "Inter", sans-serif;
}

.frame {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 8px 10px 10px;
  background: #ffffff;
  /* transparent border keeps the box geometry the enhancing ::after relies on;
     the visible 0.5px hairline + drop shadow match the surrounding cards. */
  border: 0.5px solid transparent;
  border-radius: 12px;
  /* hairline ring first so it paints on top of the drops and stays even on
     every edge (otherwise the bottom is hidden by the drop shadow) */
  box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05),
    0 2px 4px rgba(0, 0, 0, 0.02);
}
/* with chips present, match the 10px side padding on top */
.frame:has(.chips) {
  padding-top: 10px;
}

/* enhancing: a conic-gradient ring sweeps around the border */
@property --pi-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.frame[data-enhancing] {
  border-color: transparent;
}
.frame[data-enhancing]::after {
  content: "";
  position: absolute;
  inset: -0.5px;
  border-radius: 12.5px;
  /* border + padding-box mask keeps the ring an even 0.75px on every side —
     the older content-box/padding trick rendered the bottom edge thinner */
  border: 0.75px solid transparent;
  background: conic-gradient(
      from var(--pi-angle),
      #2b7fff, #8b5cf6, #d946ef, #22d3ee, #2b7fff
    )
    border-box;
  -webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  animation: pi-border-spin 1.1s linear infinite,
    pi-border-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}
@keyframes pi-border-spin {
  to { --pi-angle: 360deg; }
}
@keyframes pi-border-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* editable field — a contentEditable div so skill pills can flow inline */
.editor-wrap {
  position: relative;
}
.field {
  position: relative;
  width: 100%;
  margin: 0;
  outline: 0;
  background: transparent;
  color: #1a1a1a;
  font: inherit;
  font-size: 12px;
  line-height: 18px;
  letter-spacing: -0.12px;
  min-height: 18px;
  max-height: 160px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
.field ::selection,
.field::selection {
  background: Highlight;
  color: HighlightText;
}
.field ::-moz-selection,
.field::-moz-selection {
  background: Highlight;
  color: HighlightText;
}
.field[data-empty]::before {
  content: attr(data-placeholder);
  position: absolute;
  top: 0;
  left: 0;
  color: #1a1a1a;
  opacity: 0.5;
  pointer-events: none;
}

/* inline skill pill — created via innerHTML, so styled globally (scoped
   styles wouldn't reach nodes the framework didn't render itself) */
:global(.skill-pill) {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  /* shorter than the field's 18px line-height so the pill never expands the
     line box (middle + 18px height grew the field and jumped the action gap) */
  height: 16px;
  padding: 0 0 0 5px;
  margin: 0 2px;
  border-radius: 999px;
  background: rgba(43, 127, 255, 0.12);
  color: #1f6feb;
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.12px;
  white-space: nowrap;
  position: relative;
  top: -1px;
  vertical-align: baseline;
  user-select: none;
  transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
    filter 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* leave the same soft way the enhance pill arrives (transition-based so it
   works on the globally-styled, innerHTML-inserted node) */
:global(.skill-pill[data-exit]) {
  opacity: 0;
  transform: scale(0.96);
  filter: blur(2px);
  pointer-events: none;
}
/* at the very start of the field: drop the left margin, keep only the right
   (data-start is set in JS since :first-child ignores leading text nodes) */
:global(.skill-pill[data-start]) {
  margin-left: 0;
}
:global(.skill-pill-label) {
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
}
:global(.skill-pill-x) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: inherit;
  opacity: 0.65;
  cursor: pointer;
  transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1),
    background 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.skill-pill-x):hover {
  opacity: 1;
  background: rgba(43, 127, 255, 0.16);
}

/* "/" command palette — same container as the + menu, pinned above the field */
.slash-menu {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 25;
  width: 200px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  transform-origin: bottom left;
  animation: pi-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.slash-label {
  padding: 3px 7px;
  font-size: 11px;
  font-weight: 425;
  color: #a1a1a1;
}
.slash-empty {
  padding: 6px 7px;
  font-size: 11px;
  color: #a1a1a1;
}

.enhancing-text {
  font-size: 12px;
  line-height: 18px;
  letter-spacing: -0.12px;
  word-break: break-word;
  color: transparent;
  -webkit-text-fill-color: transparent;
  background: linear-gradient(
    90deg,
    #1a1a1a 0%, #1a1a1a 30%,
    rgba(26, 26, 26, 0.45) 45%, rgba(26, 26, 26, 0.45) 55%,
    #1a1a1a 70%, #1a1a1a 100%
  );
  background-size: 300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  animation: pi-shine 2.25s cubic-bezier(0.25, 0.1, 0.25, 1) infinite;
}
@keyframes pi-shine {
  0%, 18% { background-position: 100% 0; }
  82%, 100% { background-position: 0% 0; }
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  /* tighten only the chips->text gap (frame gap is 12px) without touching the
     text->button-row gap */
  margin-bottom: -6px;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  padding: 3px 4px 3px 5px;
  border-radius: 999px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
  color: #1a1a1a;
  font-size: 11px;
  line-height: 14px;
  animation: pi-chip-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* leave the same soft fade/scale way the skill pills do */
.chip[data-exit] {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}
.chip-icon {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
.chip-name {
  max-width: 150px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chip-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* pull 2px closer to the filename without changing the icon->text gap */
  margin-left: -2px;
  width: 15px;
  height: 15px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: #a1a1a1;
  cursor: pointer;
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.chip-remove:hover {
  background: rgba(26, 26, 26, 0.08);
  color: #1a1a1a;
}
@keyframes pi-chip-in {
  from { opacity: 0; transform: translateY(4px); filter: blur(2px); }
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
}

.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.plus-wrap {
  position: relative;
  display: flex;
}
.right {
  display: flex;
  align-items: center;
  gap: 6px;
}

.icon-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  flex: none;
  border: 0;
  background: transparent;
  color: #1a1a1a;
  cursor: pointer;
}
.icon-btn::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: rgba(26, 26, 26, 0.06);
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.icon-btn:hover::before {
  background: rgba(26, 26, 26, 0.1);
}
.icon-btn:active::before {
  transform: scale(0.98);
}
/* keep the icon above the (opaque, when active) ::before fill */
.icon-btn > svg {
  position: relative;
}

.plus-icon {
  position: relative;
  display: inline-flex;
  transition: transform 200ms cubic-bezier(0.35, 1.55, 0.65, 1);
}
.plus[data-open]::before {
  background: rgba(26, 26, 26, 0.12);
}
.plus[data-open] .plus-icon {
  transform: rotate(45deg);
}

.pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 8px;
  border: 0;
  background: transparent;
  color: #1a1a1a;
  font-size: 11px;
  line-height: 12px;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  animation: pi-pill-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.pill::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: rgba(26, 26, 26, 0.06);
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.pill:hover::before {
  background: rgba(26, 26, 26, 0.1);
}
.pill:active::before {
  transform: scale(0.98);
}
@keyframes pi-pill-in {
  from { opacity: 0; transform: scale(0.96); filter: blur(2px); }
  to { opacity: 1; transform: scale(1); filter: blur(0); }
}
/* symmetric exit — mirrors pi-pill-in so the enhance pill (and the inline
   skill pills) leave the same soft way they arrive */
@keyframes pi-pill-out {
  from { opacity: 1; transform: scale(1); filter: blur(0); }
  to { opacity: 0; transform: scale(0.96); filter: blur(2px); }
}
.pill.pill-exit {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}

.send {
  color: #a1a1a1;
}
.send:disabled {
  cursor: default;
}
.send:disabled:active::before {
  transform: none;
}
.send-active {
  color: #ffffff;
}
.send-active::before {
  background: #0b0d12;
}
.send-active:hover::before {
  background: #2a2f3a;
}

.spinner-btn {
  cursor: default;
}
.spinner {
  position: relative;
  display: inline-flex;
  color: #a1a1a1;
  animation: pi-spin 0.7s linear infinite;
}
@keyframes pi-spin {
  to { transform: rotate(360deg); }
}

.menu {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 0;
  z-index: 20;
  width: 180px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  transform-origin: bottom left;
  animation: pi-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.menu-item {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  height: 26px;
  padding: 0 7px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: #1a1a1a;
  font-size: 11px;
  font-weight: 425;
  line-height: 12px;
  text-align: left;
  cursor: pointer;
}
.menu-item:hover {
  background: rgba(26, 26, 26, 0.06);
}
.menu-item:active {
  background: rgba(26, 26, 26, 0.09);
}
.menu-item.menu-item-active {
  background: rgba(26, 26, 26, 0.06);
}
.slash-menu[data-keyboard] .menu-item:hover {
  background: transparent;
}
.slash-menu[data-keyboard] .menu-item.menu-item-active,
.slash-menu[data-keyboard] .menu-item.menu-item-active:hover {
  background: rgba(26, 26, 26, 0.06);
}
.wrap svg {
  stroke-width: 1.5px;
}
.menu-icon {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
.menu-name {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.menu-check {
  display: inline-flex;
  flex: none;
  color: #1a1a1a;
}

/* Skills — a side flyout that expands from the "Skills" row */
.menu-sub {
  position: relative;
}
.menu-chevron {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
/* brand marks keep their own colours; ChatGPT is monochrome so it follows text */
.menu-brand {
  display: inline-flex;
  flex: none;
  color: #1a1a1a;
}
.menu-flyout {
  position: absolute;
  top: -3px;
  left: calc(100% + 6px);
  width: 168px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
}
/* invisible bridge across the 6px gap so the hover doesn't drop when the
   pointer travels from the row into the flyout */
.menu-flyout::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -7px;
  width: 7px;
}

/* model info popover — a non-interactive card shown on hover to the right */
.menu-popover {
  position: absolute;
  top: -3px;
  left: calc(100% + 6px);
  z-index: 30;
  width: 200px;
  padding: 10px 12px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  pointer-events: none;
}
.popover-title {
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: #1a1a1a;
}
.popover-desc {
  margin: 2px 0 0;
  font-size: 11px;
  line-height: 15px;
  color: #a1a1a1;
}
.popover-meta {
  margin-top: 8px;
  font-size: 11px;
  line-height: 14px;
  color: #a1a1a1;
}
.menu-divider {
  height: 0.5px;
  margin: 4px -3px;
  background: #e6e8ec;
}
.menu-label {
  padding: 3px 7px;
  font-size: 11px;
  font-weight: 425;
  color: #a1a1a1;
}
@keyframes pi-menu-in {
  from { opacity: 0; transform: translateY(6px) scale(0.98); filter: blur(2px); }
  to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}

@media (prefers-color-scheme: dark) {
  .frame {
    background: #1a1a1a;
    box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.12),
      0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
  }
  .frame[data-enhancing]::after {
    background: conic-gradient(
        from var(--pi-angle),
        #3b6fb5, #6b5aa6, #9a4f96, #3a8a9a, #3b6fb5
      )
      border-box;
  }
  .field { color: #f5f5f5; }
  .field::placeholder { color: #f5f5f5; }
  .enhancing-text {
    background: linear-gradient(
      90deg,
      #f5f5f5 0%, #f5f5f5 30%,
      rgba(245, 245, 245, 0.45) 45%, rgba(245, 245, 245, 0.45) 55%,
      #f5f5f5 70%, #f5f5f5 100%
    );
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
  }
  .chip { background: #1a1a1a; border-color: #303030; color: #f5f5f5; }
  .chip-icon { color: #a3a3a3; }
  .chip-remove { color: #a3a3a3; }
  .chip-remove:hover { background: rgba(245, 245, 245, 0.08); color: #f5f5f5; }
  .icon-btn { color: #f5f5f5; }
  .icon-btn::before { background: rgba(245, 245, 245, 0.06); }
  .icon-btn:hover::before { background: rgba(245, 245, 245, 0.1); }
  .plus[data-open]::before { background: rgba(245, 245, 245, 0.12); }
  .pill { color: #f5f5f5; }
  .pill::before { background: rgba(245, 245, 245, 0.06); }
  .pill:hover::before { background: rgba(245, 245, 245, 0.1); }
  .send-active { color: #0a0a0a; }
  .send-active::before { background: #f5f5f5; }
  .send-active:hover::before { background: #ffffff; }
  .spinner { color: #a3a3a3; }
  .menu { background: #1a1a1a; border-color: #303030; }
  .menu-item { color: #f5f5f5; }
  .menu-item:hover { background: rgba(245, 245, 245, 0.06); }
  .menu-item:active { background: rgba(245, 245, 245, 0.09); }
  .menu-icon { color: #a3a3a3; }
  .menu-check { color: #f5f5f5; }
  .menu-chevron { color: #a3a3a3; }
  .menu-brand { color: #f5f5f5; }
  .menu-divider { background: #303030; }
  .menu-label { color: #a3a3a3; }
  .menu-flyout { background: #1a1a1a; border-color: #303030; }
  .menu-popover { background: #1a1a1a; border-color: #303030; }
  .popover-title { color: #f5f5f5; }
  .popover-desc, .popover-meta { color: #a3a3a3; }
  :global(.skill-pill) { background: rgba(43, 127, 255, 0.22); color: #9ec5ff; }
  .slash-menu { background: #1a1a1a; border-color: #303030; }
  .slash-label, .slash-empty { color: #a3a3a3; }
  .menu-item.menu-item-active { background: rgba(245, 245, 245, 0.06); }
  .slash-menu[data-keyboard] .menu-item.menu-item-active,
  .slash-menu[data-keyboard] .menu-item.menu-item-active:hover {
    background: rgba(245, 245, 245, 0.06);
  }
}

@media (prefers-reduced-motion: reduce) {
  .icon-btn::before, .pill::before, .menu-item, .chip-remove, .plus-icon, :global(.skill-pill) { transition: none; }
  .chip, .chip[data-exit], .pill, .pill.pill-exit, .menu, .menu-flyout, .menu-popover, .slash-menu { animation: none; }
  .enhancing-text, .frame[data-enhancing]::after { animation: none; }
  .spinner { animation-duration: 1.4s; }
}
</style>
```

### Svelte — `PromptInput.svelte`

```svelte
<script lang="ts">
  import { afterUpdate, onDestroy, tick } from "svelte";
  import {
    ArrowUp,
    BookOpen,
    Check,
    ChevronRight,
    Image as ImageIcon,
    Loader2,
    Paperclip,
    Plus,
    X,
  } from "lucide-svelte";

  const ENHANCED =
    "This is an example prompt — rewritten to be clear and specific: state the goal, add the relevant context and constraints, define the expected output format and tone, and note any assumptions. Ask a clarifying question first if key details are missing.";

  /**
   * Integration seam: replace the mock body with a real request to your
   * model/API. It only needs to resolve to the enhanced prompt string.
   */
  async function mockEnhance(prompt: string, signal?: AbortSignal): Promise<string> {
    await new Promise((r) => setTimeout(r, 2500));
    if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
    return ENHANCED;
    // const res = await fetch("/api/enhance", {
    //   method: "POST",
    //   headers: { "Content-Type": "application/json" },
    //   body: JSON.stringify({ prompt }),
    //   signal,
    // });
    // if (!res.ok) throw new Error("Enhance request failed");
    // return (await res.json()).prompt as string;
  }

  export let onEnhance: (prompt: string, signal?: AbortSignal) => Promise<string> =
    mockEnhance;

  const MODELS = [
    {
      id: "claude-opus-4.8",
      name: "Claude Opus 4.8",
      desc: "Anthropic's most capable model — best for complex, multi-step reasoning.",
      context: "200k context window",
    },
    {
      id: "gpt-5.6",
      name: "GPT-5.6",
      desc: "OpenAI's flagship — strong all-round performance and tool use.",
      context: "400k context window",
    },
    {
      id: "gemini-2.5-pro",
      name: "Gemini 2.5 Pro",
      desc: "Google's long-context model — great for large documents and codebases.",
      context: "1M context window",
    },
  ];

  const SKILLS = [
    { id: "deep-research", name: "Deep Research" },
    { id: "code-review", name: "Code Review" },
    { id: "web-search", name: "Web Search" },
    { id: "summarize", name: "Summarize" },
  ];

  const skillName = (id: string) => SKILLS.find((sk) => sk.id === id)?.name ?? id;
  const escapeHtml = (str: string) =>
    str.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] ?? c));

  type Phase = "idle" | "enhancing" | "enhanced";
  type Attachment = { id: number; name: string; kind: "image" | "file" };

  let value = "";
  let phase: Phase = "idle";
  let menuOpen = false;
  let skillsOpen = false;
  let hoveredModel: string | null = null;
  let model = MODELS[0].id;
  let attachments: Attachment[] = [];
  // ids of chips currently playing their exit animation before removal
  let exitingAtt: number[] = [];

  let slashOpen = false;
  let slashQuery = "";
  let slashIndex = 0;
  let slashKeyboard = false;
  let lastSlashQuery = "";
  let ignoreHover = false;
  let slashKeyCleanup: (() => void) | null = null;

  // Collapse the Skills flyout / model popover whenever the menu closes.
  $: if (!menuOpen) {
    skillsOpen = false;
    hoveredModel = null;
  }

  let editor: HTMLElement;
  let frame: HTMLElement;
  let plusWrap: HTMLElement;
  let fileInput: HTMLInputElement;
  let preEnhanceHTML = "";
  let pendingHTML: string | null = null;
  // height of the frame captured right before an enhance/revert swap, so the
  // new height can be animated from it (FLIP) instead of jumping.
  let flipFrom: number | null = null;
  let savedRange: Range | null = null;
  let abort: AbortController | null = null;
  let nextId = 1;

  // Keep the enhance pill mounted through a short exit so it leaves the same
  // soft way it arrives (mirrors pi-pill-in / pi-pill-out).
  let pillMounted = false;
  let pillExiting = false;
  let pillTimer: ReturnType<typeof setTimeout> | null = null;

  $: hasText = value.trim().length > 0;
  $: enhancing = phase === "enhancing";
  $: sendActive = hasText && !enhancing;
  $: showPill = hasText && !enhancing;
  $: slashResults = SKILLS.filter((sk) =>
    sk.name.toLowerCase().includes(slashQuery.toLowerCase()),
  );

  // Drive the enhance pill's mount/exit — enter with text, play the exit first
  // when leaving, but swap instantly when handing over to the spinner.
  function updatePill(show: boolean, isEnhancing: boolean) {
    if (show) {
      pillMounted = true;
      pillExiting = false;
      if (pillTimer) {
        clearTimeout(pillTimer);
        pillTimer = null;
      }
      return;
    }
    if (!pillMounted) return;
    if (isEnhancing) {
      pillMounted = false;
      pillExiting = false;
      return;
    }
    pillExiting = true;
    if (pillTimer) clearTimeout(pillTimer);
    pillTimer = setTimeout(() => {
      pillMounted = false;
      pillExiting = false;
      pillTimer = null;
    }, 200);
  }
  $: updatePill(showPill, enhancing);

  // Focus the editor and drop the caret at the very end of its content.
  function focusEnd() {
    if (!editor) return;
    editor.focus();
    const range = document.createRange();
    range.selectNodeContents(editor);
    range.collapse(false);
    const sel = window.getSelection();
    sel?.removeAllRanges();
    sel?.addRange(range);
    savedRange = range.cloneRange();
  }
  function syncFromEditor() {
    if (!editor) return;
    value = editor.textContent ?? "";
    // Mark pills at the very start (nothing but whitespace before them) so CSS
    // can drop their left margin — :first-child can't see leading text nodes.
    editor.querySelectorAll<HTMLElement>(".skill-pill").forEach((pill) => {
      let atStart = true;
      for (let n = pill.previousSibling; n; n = n.previousSibling) {
        if (n.nodeType === Node.TEXT_NODE && (n.textContent ?? "").trim() === "") continue;
        atStart = false;
        break;
      }
      pill.toggleAttribute("data-start", atStart);
    });
  }
  function saveSelection() {
    const sel = window.getSelection();
    if (sel && sel.rangeCount && editor && editor.contains(sel.anchorNode)) {
      savedRange = sel.getRangeAt(0).cloneRange();
    }
  }
  function closeSlash() {
    slashOpen = false;
    slashQuery = "";
    slashIndex = 0;
    slashKeyboard = false;
    lastSlashQuery = "";
    ignoreHover = false;
  }
  function buildPill(id: string) {
    const name = skillName(id);
    const el = document.createElement("span");
    el.className = "skill-pill";
    el.setAttribute("contenteditable", "false");
    el.dataset.skill = id;
    el.innerHTML =
      '<span class="skill-pill-label">/' + escapeHtml(name) + "</span>" +
      '<button type="button" class="skill-pill-x" data-remove="1" aria-label="Remove ' +
      escapeHtml(name) + '"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
    return el;
  }
  function insertPillOverRange(range: Range, id: string) {
    if (!editor) return;
    range.deleteContents();
    const pill = buildPill(id);
    range.insertNode(pill);
    const space = document.createTextNode("\u00A0");
    pill.after(space);
    const after = document.createRange();
    after.setStartAfter(space);
    after.collapse(true);
    const sel = window.getSelection();
    sel?.removeAllRanges();
    sel?.addRange(after);
    editor.focus();
    savedRange = after.cloneRange();
    syncFromEditor();
  }
  function addSkillFromMenu(id: string) {
    if (!editor) return;
    const sel = window.getSelection();
    let range: Range | null = null;
    if (sel && sel.rangeCount && editor.contains(sel.anchorNode)) {
      range = sel.getRangeAt(0).cloneRange();
    } else if (savedRange && editor.contains(savedRange.startContainer)) {
      range = savedRange.cloneRange();
    }
    if (!range) {
      range = document.createRange();
      range.selectNodeContents(editor);
      range.collapse(false);
    }
    insertPillOverRange(range, id);
    menuOpen = false;
  }
  function applySlash(id: string) {
    const sel = window.getSelection();
    if (!editor || !sel || !sel.rangeCount) return closeSlash();
    const caret = sel.getRangeAt(0);
    let range = caret.cloneRange();
    const node = caret.startContainer;
    if (node.nodeType === Node.TEXT_NODE && editor.contains(node)) {
      const before = (node.textContent ?? "").slice(0, caret.startOffset);
      const m = before.match(/\/([^\s/]*)$/);
      if (m) {
        range = document.createRange();
        range.setStart(node, caret.startOffset - m[0].length);
        range.setEnd(node, caret.startOffset);
      }
    }
    insertPillOverRange(range, id);
    closeSlash();
  }
  function detectSlash() {
    const sel = window.getSelection();
    if (!editor || !sel || !sel.rangeCount || !sel.isCollapsed) return closeSlash();
    const range = sel.getRangeAt(0);
    const node = range.startContainer;
    if (node.nodeType !== Node.TEXT_NODE || !editor.contains(node)) return closeSlash();
    const before = (node.textContent ?? "").slice(0, range.startOffset);
    const m = before.match(/(?:^|\s)\/([^\s/]*)$/);
    if (!m) return closeSlash();
    const q = m[1];
    if (q !== lastSlashQuery) {
      lastSlashQuery = q;
      slashIndex = 0;
    }
    slashQuery = q;
    slashOpen = true;
  }
  function onEditorInput() {
    syncFromEditor();
    if (phase === "enhanced") phase = "idle";
    detectSlash();
  }
  function moveSlash(delta: number) {
    if (!slashResults.length) return;
    ignoreHover = true;
    slashKeyboard = true;
    slashIndex = (slashIndex + delta + slashResults.length * 10) % slashResults.length;
  }
  function onSlashMouseEnter(i: number) {
    if (ignoreHover) return;
    slashIndex = i;
  }
  function onSlashMouseMove() {
    ignoreHover = false;
    slashKeyboard = false;
  }
  function bindSlashKeys(open: boolean) {
    if (slashKeyCleanup) {
      slashKeyCleanup();
      slashKeyCleanup = null;
    }
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (!slashOpen || !slashResults.length) return;
      if (e.key === "ArrowDown") {
        e.preventDefault();
        e.stopPropagation();
        moveSlash(1);
        return;
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        e.stopPropagation();
        moveSlash(-1);
        return;
      }
      if (e.key === "Enter" || e.key === "Tab") {
        e.preventDefault();
        e.stopPropagation();
        applySlash((slashResults[slashIndex] ?? slashResults[0]).id);
        return;
      }
      if (e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();
        closeSlash();
      }
    };
    window.addEventListener("keydown", onKey, true);
    slashKeyCleanup = () => window.removeEventListener("keydown", onKey, true);
  }
  $: bindSlashKeys(slashOpen);
  $: if (slashOpen && slashResults.length && slashIndex >= slashResults.length) slashIndex = 0;
  onDestroy(() => slashKeyCleanup?.());
  function onEditorKeydown(e: KeyboardEvent) {
    if (
      slashOpen &&
      slashResults.length &&
      (e.key === "ArrowDown" ||
        e.key === "ArrowUp" ||
        e.key === "Enter" ||
        e.key === "Tab" ||
        e.key === "Escape")
    ) {
      e.preventDefault();
      return;
    }
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      send();
    }
  }
  function onEditorClick(e: MouseEvent) {
    const remove = (e.target as HTMLElement).closest("[data-remove]");
    if (remove) {
      e.preventDefault();
      const pill = remove.closest<HTMLElement>("[data-skill]");
      if (pill) {
        // the separator space we inserted right after the pill — drop it too
        // on removal so leftover spaces can't accumulate and shift the next
        // pill out of alignment.
        const sep = pill.nextSibling;
        // collapse the pill's footprint (width + margins + padding) in sync with
        // the fade so following text slides in smoothly instead of snapping.
        const w = pill.getBoundingClientRect().width;
        pill.style.maxWidth = `${w}px`;
        pill.style.overflow = "hidden";
        pill.style.whiteSpace = "nowrap";
        void pill.offsetWidth;
        pill.style.transition =
          "opacity 180ms cubic-bezier(0.22,1,0.36,1), transform 180ms cubic-bezier(0.22,1,0.36,1), filter 180ms cubic-bezier(0.22,1,0.36,1), max-width 180ms cubic-bezier(0.22,1,0.36,1), margin 180ms cubic-bezier(0.22,1,0.36,1), padding 180ms cubic-bezier(0.22,1,0.36,1)";
        // leave the same soft way the enhance pill arrives, then drop the node
        pill.setAttribute("data-exit", "");
        pill.style.maxWidth = "0px";
        pill.style.marginLeft = "0px";
        pill.style.marginRight = "0px";
        pill.style.paddingLeft = "0px";
        pill.style.paddingRight = "0px";
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          if (sep && sep.nodeType === Node.TEXT_NODE && sep.textContent?.startsWith("\u00A0")) {
            const rest = sep.textContent.slice(1);
            if (rest) sep.textContent = rest;
            else sep.parentNode?.removeChild(sep);
          }
          pill.remove();
          syncFromEditor();
          editor?.focus();
        };
        pill.addEventListener("transitionend", finish, { once: true });
        setTimeout(finish, 220);
      }
      return;
    }
    saveSelection();
  }

  async function enhance() {
    if (!hasText || enhancing) return;
    preEnhanceHTML = editor?.innerHTML ?? "";
    phase = "enhancing";
    const ac = new AbortController();
    abort = ac;
    try {
      const result = await onEnhance(value, ac.signal);
      if (ac.signal.aborted) return;
      pendingHTML = escapeHtml(result);
      flipFrom = frame?.offsetHeight ?? null;
      phase = "enhanced";
    } catch {
      if (ac.signal.aborted) return;
      pendingHTML = preEnhanceHTML;
      phase = "idle";
    }
  }
  function revert() {
    abort?.abort();
    pendingHTML = preEnhanceHTML;
    flipFrom = frame?.offsetHeight ?? null;
    phase = "idle";
  }
  async function send() {
    if (!sendActive) return;
    if (editor) editor.innerHTML = "";
    value = "";
    phase = "idle";
    attachments = [];
    exitingAtt = [];
    closeSlash();
    await tick();
    editor?.focus();
  }

  // After an enhance/revert the editor is shown editable again — write the
  // pending HTML into it (enhanced text, or the restored original w/ pills).
  afterUpdate(() => {
    if (enhancing || pendingHTML === null || !editor) return;
    editor.innerHTML = pendingHTML;
    pendingHTML = null;
    syncFromEditor();
    requestAnimationFrame(focusEnd);

    // Animate the frame from its previous height to the new one so the input
    // doesn't jump when the enhanced/original text changes its size.
    const from = flipFrom;
    flipFrom = null;
    if (!frame || from === null) return;
    const to = frame.offsetHeight;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduce || from === to) return;
    frame.style.height = from + "px";
    frame.style.overflow = "hidden";
    void frame.offsetHeight; // force reflow so the start height is committed
    frame.style.transition = "height 200ms cubic-bezier(0.22, 1, 0.36, 1)";
    frame.style.height = to + "px";
    let animDone = false;
    const finishFlip = () => {
      if (animDone) return;
      animDone = true;
      frame.style.transition = "";
      frame.style.height = "";
      frame.style.overflow = "";
      frame.removeEventListener("transitionend", finishFlip);
    };
    frame.addEventListener("transitionend", finishFlip);
    setTimeout(finishFlip, 260);
  });

  function openPicker(kind: Attachment["kind"]) {
    if (!fileInput) return;
    fileInput.accept = kind === "image" ? "image/*" : "";
    fileInput.value = "";
    fileInput.dataset.kind = kind;
    fileInput.click();
    menuOpen = false;
  }
  async function onFiles(e: Event) {
    const input = e.target as HTMLInputElement;
    const files = Array.from(input.files ?? []);
    if (!files.length) return;
    const fallback = (input.dataset.kind as Attachment["kind"]) ?? "file";
    attachments = [
      ...attachments,
      ...files.map((f) => ({
        id: nextId++,
        name: f.name,
        kind: f.type.startsWith("image/") ? ("image" as const) : fallback,
      })),
    ];
    input.value = "";
    await tick();
    editor?.focus();
  }
  function removeAttachment(id: number) {
    // play the same soft fade/scale exit as the skill pills, then drop the chip
    if (!exitingAtt.includes(id)) exitingAtt = [...exitingAtt, id];
    setTimeout(() => {
      attachments = attachments.filter((a) => a.id !== id);
      exitingAtt = exitingAtt.filter((x) => x !== id);
    }, 200);
  }
  function selectModel(id: string) {
    model = id;
    menuOpen = false;
  }
  function onDocDown(e: PointerEvent) {
    if (menuOpen && plusWrap && !plusWrap.contains(e.target as Node)) menuOpen = false;
  }
  function onDocKey(e: KeyboardEvent) {
    if (e.key === "Escape") menuOpen = false;
  }
  onDestroy(() => {
    abort?.abort();
    if (pillTimer) clearTimeout(pillTimer);
  });
</script>

<svelte:window on:pointerdown={onDocDown} on:keydown={onDocKey} />

<div class="wrap">
  <input bind:this={fileInput} type="file" multiple hidden on:change={onFiles} />
  <div bind:this={frame} class="frame" data-enhancing={enhancing || undefined}>
    {#if attachments.length}
      <div class="chips">
        {#each attachments as att (att.id)}
          <span class="chip" data-exit={exitingAtt.includes(att.id) || undefined}>
            <span class="chip-icon">
              {#if att.kind === "image"}<ImageIcon size={13} />{:else}<Paperclip size={13} />{/if}
            </span>
            <span class="chip-name">{att.name}</span>
            <button
              type="button"
              class="chip-remove"
              aria-label={"Remove " + att.name}
              on:click={() => removeAttachment(att.id)}
            >
              <X size={11} />
            </button>
          </span>
        {/each}
      </div>
    {/if}

    <div class="editor-wrap">
      {#if enhancing}
        <div class="enhancing-text" aria-live="polite">{value}</div>
      {:else}
        <div
          bind:this={editor}
          class="field"
          contenteditable="true"
          role="textbox"
          aria-multiline="true"
          aria-label="Ask AI Agent"
          data-empty={!hasText || undefined}
          data-placeholder="Ask AI Agent"
          on:input={onEditorInput}
          on:keydown={onEditorKeydown}
          on:keyup={saveSelection}
          on:mouseup={saveSelection}
          on:blur={saveSelection}
          on:click={onEditorClick}
        ></div>
      {/if}

      {#if slashOpen && !enhancing}
        <div
          class="slash-menu"
          role="listbox"
          aria-label="Skills"
          data-keyboard={slashKeyboard || undefined}
          on:mousemove={onSlashMouseMove}
        >
          <div class="slash-label">Skills</div>
          {#if slashResults.length}
            {#each slashResults as sk, i (sk.id)}
              <button
                type="button"
                role="option"
                aria-selected={i === slashIndex}
                class="menu-item"
                class:menu-item-active={i === slashIndex}
                on:mousedown|preventDefault
                on:mouseenter={() => onSlashMouseEnter(i)}
                on:click={() => applySlash(sk.id)}
              >
                <span class="menu-name">{sk.name}</span>
              </button>
            {/each}
          {:else}
            <div class="slash-empty">No matching skills</div>
          {/if}
        </div>
      {/if}
    </div>

    <div class="row">
      <div class="plus-wrap" bind:this={plusWrap}>
        <button
          type="button"
          class="icon-btn plus"
          data-open={menuOpen || undefined}
          aria-label="Add attachment or switch model"
          aria-expanded={menuOpen}
          on:click={() => (menuOpen = !menuOpen)}
        >
          <span class="plus-icon"><Plus size={14} /></span>
        </button>

        {#if menuOpen}
          <div class="menu" role="menu">
            <button type="button" role="menuitem" class="menu-item" on:click={() => openPicker("image")}>
              <span class="menu-icon"><ImageIcon size={14} /></span>
              <span class="menu-name">Add photos</span>
            </button>
            <button type="button" role="menuitem" class="menu-item" on:click={() => openPicker("file")}>
              <span class="menu-icon"><Paperclip size={14} /></span>
              <span class="menu-name">Attach files</span>
            </button>
            <div class="menu-divider"></div>
            <div
              class="menu-sub"
              on:mouseenter={() => (skillsOpen = true)}
              on:mouseleave={() => (skillsOpen = false)}
            >
              <button
                type="button"
                role="menuitem"
                class="menu-item"
                aria-haspopup="menu"
                aria-expanded={skillsOpen}
                on:click={() => (skillsOpen = true)}
              >
                <span class="menu-icon"><BookOpen size={14} /></span>
                <span class="menu-name">Skills</span>
                <span class="menu-chevron"><ChevronRight size={14} /></span>
              </button>
              {#if skillsOpen}
                <div class="menu-flyout" role="menu">
                  {#each SKILLS as sk (sk.id)}
                    <button
                      type="button"
                      role="menuitem"
                      class="menu-item"
                      on:click={() => addSkillFromMenu(sk.id)}
                    >
                      <span class="menu-name">{sk.name}</span>
                    </button>
                  {/each}
                </div>
              {/if}
            </div>
            <div class="menu-divider"></div>
            <div class="menu-label">Model</div>
            {#each MODELS as m (m.id)}
              <div
                class="menu-sub"
                on:mouseenter={() => (hoveredModel = m.id)}
                on:mouseleave={() => (hoveredModel = null)}
              >
                <button
                  type="button"
                  role="menuitemradio"
                  aria-checked={model === m.id}
                  class="menu-item"
                  on:click={() => selectModel(m.id)}
                >
                  <span class="menu-brand">
                    {#if m.id.startsWith("gpt")}
                      <svg width="12" height="12" viewBox="0 0 320 320" fill="currentColor" aria-hidden="true">
                        <path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z" />
                      </svg>
                    {:else if m.id.startsWith("claude")}
                      <svg width="12" height="12" viewBox="0 0 100 100" fill="#d97757" aria-hidden="true">
                        <path d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z" />
                      </svg>
                    {:else}
                      <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
                        <path d="M16 8.016A8.522 8.522 0 0 0 8.016 16h-.032A8.521 8.521 0 0 0 0 8.016v-.032A8.521 8.521 0 0 0 7.984 0h.032A8.522 8.522 0 0 0 16 7.984v.032z" fill="url(#pi-gemini-grad)" />
                        <defs>
                          <radialGradient id="pi-gemini-grad" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)">
                            <stop offset=".067" stop-color="#9168C0" />
                            <stop offset=".343" stop-color="#5684D1" />
                            <stop offset=".672" stop-color="#1BA1E3" />
                          </radialGradient>
                        </defs>
                      </svg>
                    {/if}
                  </span>
                  <span class="menu-name">{m.name}</span>
                  {#if model === m.id}<span class="menu-check"><Check size={14} /></span>{/if}
                </button>
                {#if hoveredModel === m.id}
                  <div class="menu-popover" role="tooltip">
                    <div class="popover-title">{m.name}</div>
                    <p class="popover-desc">{m.desc}</p>
                    <div class="popover-meta">{m.context}</div>
                  </div>
                {/if}
              </div>
            {/each}
          </div>
        {/if}
      </div>

      <div class="right">
        {#if enhancing}
          <span class="icon-btn spinner-btn" aria-label="Enhancing prompt">
            <Loader2 class="spinner" size={14} />
          </span>
        {:else if pillMounted}
          <button
            type="button"
            class="pill"
            class:pill-exit={pillExiting}
            on:click={() => (phase === "enhanced" ? revert() : enhance())}
          >
            {phase === "enhanced" ? "Revert" : "Enhance Prompt"}
          </button>
        {/if}
        <button
          type="button"
          class="icon-btn send"
          class:send-active={sendActive}
          aria-label="Send"
          disabled={!sendActive}
          on:click={send}
        >
          <ArrowUp size={14} />
        </button>
      </div>
    </div>
  </div>
</div>

<style>
.wrap {
  width: 100%;
  max-width: 420px;
  font-family: "Inter Variable", "Inter", sans-serif;
}

.frame {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 8px 10px 10px;
  background: #ffffff;
  /* transparent border keeps the box geometry the enhancing ::after relies on;
     the visible 0.5px hairline + drop shadow match the surrounding cards. */
  border: 0.5px solid transparent;
  border-radius: 12px;
  /* hairline ring first so it paints on top of the drops and stays even on
     every edge (otherwise the bottom is hidden by the drop shadow) */
  box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05),
    0 2px 4px rgba(0, 0, 0, 0.02);
}
/* with chips present, match the 10px side padding on top */
.frame:has(.chips) {
  padding-top: 10px;
}

/* enhancing: a conic-gradient ring sweeps around the border */
@property --pi-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.frame[data-enhancing] {
  border-color: transparent;
}
.frame[data-enhancing]::after {
  content: "";
  position: absolute;
  inset: -0.5px;
  border-radius: 12.5px;
  /* border + padding-box mask keeps the ring an even 0.75px on every side —
     the older content-box/padding trick rendered the bottom edge thinner */
  border: 0.75px solid transparent;
  background: conic-gradient(
      from var(--pi-angle),
      #2b7fff, #8b5cf6, #d946ef, #22d3ee, #2b7fff
    )
    border-box;
  -webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  animation: pi-border-spin 1.1s linear infinite,
    pi-border-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}
@keyframes pi-border-spin {
  to { --pi-angle: 360deg; }
}
@keyframes pi-border-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* editable field — a contentEditable div so skill pills can flow inline */
.editor-wrap {
  position: relative;
}
.field {
  position: relative;
  width: 100%;
  margin: 0;
  outline: 0;
  background: transparent;
  color: #1a1a1a;
  font: inherit;
  font-size: 12px;
  line-height: 18px;
  letter-spacing: -0.12px;
  min-height: 18px;
  max-height: 160px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
.field ::selection,
.field::selection {
  background: Highlight;
  color: HighlightText;
}
.field ::-moz-selection,
.field::-moz-selection {
  background: Highlight;
  color: HighlightText;
}
.field[data-empty]::before {
  content: attr(data-placeholder);
  position: absolute;
  top: 0;
  left: 0;
  color: #1a1a1a;
  opacity: 0.5;
  pointer-events: none;
}

/* inline skill pill — created via innerHTML, so styled globally (scoped
   styles wouldn't reach nodes the framework didn't render itself) */
:global(.skill-pill) {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  /* shorter than the field's 18px line-height so the pill never expands the
     line box (middle + 18px height grew the field and jumped the action gap) */
  height: 16px;
  padding: 0 0 0 5px;
  margin: 0 2px;
  border-radius: 999px;
  background: rgba(43, 127, 255, 0.12);
  color: #1f6feb;
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.12px;
  white-space: nowrap;
  position: relative;
  top: -1px;
  vertical-align: baseline;
  user-select: none;
  transition: opacity 180ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
    filter 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* leave the same soft way the enhance pill arrives (transition-based so it
   works on the globally-styled, innerHTML-inserted node) */
:global(.skill-pill[data-exit]) {
  opacity: 0;
  transform: scale(0.96);
  filter: blur(2px);
  pointer-events: none;
}
/* at the very start of the field: drop the left margin, keep only the right
   (data-start is set in JS since :first-child ignores leading text nodes) */
:global(.skill-pill[data-start]) {
  margin-left: 0;
}
:global(.skill-pill-label) {
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
}
:global(.skill-pill-x) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: inherit;
  opacity: 0.65;
  cursor: pointer;
  transition: opacity 150ms cubic-bezier(0.22, 1, 0.36, 1),
    background 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
:global(.skill-pill-x):hover {
  opacity: 1;
  background: rgba(43, 127, 255, 0.16);
}

/* "/" command palette — same container as the + menu, pinned above the field */
.slash-menu {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 25;
  width: 200px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  transform-origin: bottom left;
  animation: pi-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.slash-label {
  padding: 3px 7px;
  font-size: 11px;
  font-weight: 425;
  color: #a1a1a1;
}
.slash-empty {
  padding: 6px 7px;
  font-size: 11px;
  color: #a1a1a1;
}

.enhancing-text {
  font-size: 12px;
  line-height: 18px;
  letter-spacing: -0.12px;
  word-break: break-word;
  color: transparent;
  -webkit-text-fill-color: transparent;
  background: linear-gradient(
    90deg,
    #1a1a1a 0%, #1a1a1a 30%,
    rgba(26, 26, 26, 0.45) 45%, rgba(26, 26, 26, 0.45) 55%,
    #1a1a1a 70%, #1a1a1a 100%
  );
  background-size: 300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  animation: pi-shine 2.25s cubic-bezier(0.25, 0.1, 0.25, 1) infinite;
}
@keyframes pi-shine {
  0%, 18% { background-position: 100% 0; }
  82%, 100% { background-position: 0% 0; }
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  /* tighten only the chips->text gap (frame gap is 12px) without touching the
     text->button-row gap */
  margin-bottom: -6px;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  padding: 3px 4px 3px 5px;
  border-radius: 999px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
  color: #1a1a1a;
  font-size: 11px;
  line-height: 14px;
  animation: pi-chip-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* leave the same soft fade/scale way the skill pills do */
.chip[data-exit] {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}
.chip-icon {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
.chip-name {
  max-width: 150px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.chip-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* pull 2px closer to the filename without changing the icon->text gap */
  margin-left: -2px;
  width: 15px;
  height: 15px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: #a1a1a1;
  cursor: pointer;
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    color 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.chip-remove:hover {
  background: rgba(26, 26, 26, 0.08);
  color: #1a1a1a;
}
@keyframes pi-chip-in {
  from { opacity: 0; transform: translateY(4px); filter: blur(2px); }
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
}

.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.plus-wrap {
  position: relative;
  display: flex;
}
.right {
  display: flex;
  align-items: center;
  gap: 6px;
}

.icon-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  flex: none;
  border: 0;
  background: transparent;
  color: #1a1a1a;
  cursor: pointer;
}
.icon-btn::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: rgba(26, 26, 26, 0.06);
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.icon-btn:hover::before {
  background: rgba(26, 26, 26, 0.1);
}
.icon-btn:active::before {
  transform: scale(0.98);
}
/* keep the icon above the (opaque, when active) ::before fill */
.icon-btn > svg {
  position: relative;
}

.plus-icon {
  position: relative;
  display: inline-flex;
  transition: transform 200ms cubic-bezier(0.35, 1.55, 0.65, 1);
}
.plus[data-open]::before {
  background: rgba(26, 26, 26, 0.12);
}
.plus[data-open] .plus-icon {
  transform: rotate(45deg);
}

.pill {
  position: relative;
  display: inline-flex;
  align-items: center;
  height: 22px;
  padding: 0 8px;
  border: 0;
  background: transparent;
  color: #1a1a1a;
  font-size: 11px;
  line-height: 12px;
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  animation: pi-pill-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.pill::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 999px;
  background: rgba(26, 26, 26, 0.06);
  transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 150ms cubic-bezier(0.22, 1, 0.36, 1);
}
.pill:hover::before {
  background: rgba(26, 26, 26, 0.1);
}
.pill:active::before {
  transform: scale(0.98);
}
@keyframes pi-pill-in {
  from { opacity: 0; transform: scale(0.96); filter: blur(2px); }
  to { opacity: 1; transform: scale(1); filter: blur(0); }
}
/* symmetric exit — mirrors pi-pill-in so the enhance pill (and the inline
   skill pills) leave the same soft way they arrive */
@keyframes pi-pill-out {
  from { opacity: 1; transform: scale(1); filter: blur(0); }
  to { opacity: 0; transform: scale(0.96); filter: blur(2px); }
}
.pill.pill-exit {
  animation: pi-pill-out 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
  pointer-events: none;
}

.send {
  color: #a1a1a1;
}
.send:disabled {
  cursor: default;
}
.send:disabled:active::before {
  transform: none;
}
.send-active {
  color: #ffffff;
}
.send-active::before {
  background: #0b0d12;
}
.send-active:hover::before {
  background: #2a2f3a;
}

.spinner-btn {
  cursor: default;
}
.spinner {
  position: relative;
  display: inline-flex;
  color: #a1a1a1;
  animation: pi-spin 0.7s linear infinite;
}
@keyframes pi-spin {
  to { transform: rotate(360deg); }
}

.menu {
  position: absolute;
  bottom: calc(100% + 4px);
  left: 0;
  z-index: 20;
  width: 180px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  transform-origin: bottom left;
  animation: pi-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.menu-item {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  height: 26px;
  padding: 0 7px;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: #1a1a1a;
  font-size: 11px;
  font-weight: 425;
  line-height: 12px;
  text-align: left;
  cursor: pointer;
}
.menu-item:hover {
  background: rgba(26, 26, 26, 0.06);
}
.menu-item:active {
  background: rgba(26, 26, 26, 0.09);
}
.menu-item.menu-item-active {
  background: rgba(26, 26, 26, 0.06);
}
.slash-menu[data-keyboard] .menu-item:hover {
  background: transparent;
}
.slash-menu[data-keyboard] .menu-item.menu-item-active,
.slash-menu[data-keyboard] .menu-item.menu-item-active:hover {
  background: rgba(26, 26, 26, 0.06);
}
.wrap svg {
  stroke-width: 1.5px;
}
.menu-icon {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
.menu-name {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.menu-check {
  display: inline-flex;
  flex: none;
  color: #1a1a1a;
}

/* Skills — a side flyout that expands from the "Skills" row */
.menu-sub {
  position: relative;
}
.menu-chevron {
  display: inline-flex;
  flex: none;
  color: #a1a1a1;
}
/* brand marks keep their own colours; ChatGPT is monochrome so it follows text */
.menu-brand {
  display: inline-flex;
  flex: none;
  color: #1a1a1a;
}
.menu-flyout {
  position: absolute;
  top: -3px;
  left: calc(100% + 6px);
  width: 168px;
  padding: 3px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
}
/* invisible bridge across the 6px gap so the hover doesn't drop when the
   pointer travels from the row into the flyout */
.menu-flyout::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -7px;
  width: 7px;
}

/* model info popover — a non-interactive card shown on hover to the right */
.menu-popover {
  position: absolute;
  top: -3px;
  left: calc(100% + 6px);
  z-index: 30;
  width: 200px;
  padding: 10px 12px;
  background: #ffffff;
  border: 0.5px solid #e6e8ec;
  border-radius: 10px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02), 0 1px 1px rgba(0, 0, 0, 0.04);
  pointer-events: none;
}
.popover-title {
  font-size: 12px;
  font-weight: 500;
  line-height: 16px;
  color: #1a1a1a;
}
.popover-desc {
  margin: 2px 0 0;
  font-size: 11px;
  line-height: 15px;
  color: #a1a1a1;
}
.popover-meta {
  margin-top: 8px;
  font-size: 11px;
  line-height: 14px;
  color: #a1a1a1;
}
.menu-divider {
  height: 0.5px;
  margin: 4px -3px;
  background: #e6e8ec;
}
.menu-label {
  padding: 3px 7px;
  font-size: 11px;
  font-weight: 425;
  color: #a1a1a1;
}
@keyframes pi-menu-in {
  from { opacity: 0; transform: translateY(6px) scale(0.98); filter: blur(2px); }
  to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}

@media (prefers-color-scheme: dark) {
  .frame {
    background: #1a1a1a;
    box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.12),
      0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
  }
  .frame[data-enhancing]::after {
    background: conic-gradient(
        from var(--pi-angle),
        #3b6fb5, #6b5aa6, #9a4f96, #3a8a9a, #3b6fb5
      )
      border-box;
  }
  .field { color: #f5f5f5; }
  .field::placeholder { color: #f5f5f5; }
  .enhancing-text {
    background: linear-gradient(
      90deg,
      #f5f5f5 0%, #f5f5f5 30%,
      rgba(245, 245, 245, 0.45) 45%, rgba(245, 245, 245, 0.45) 55%,
      #f5f5f5 70%, #f5f5f5 100%
    );
    background-size: 300% 100%;
    -webkit-background-clip: text;
    background-clip: text;
  }
  .chip { background: #1a1a1a; border-color: #303030; color: #f5f5f5; }
  .chip-icon { color: #a3a3a3; }
  .chip-remove { color: #a3a3a3; }
  .chip-remove:hover { background: rgba(245, 245, 245, 0.08); color: #f5f5f5; }
  .icon-btn { color: #f5f5f5; }
  .icon-btn::before { background: rgba(245, 245, 245, 0.06); }
  .icon-btn:hover::before { background: rgba(245, 245, 245, 0.1); }
  .plus[data-open]::before { background: rgba(245, 245, 245, 0.12); }
  .pill { color: #f5f5f5; }
  .pill::before { background: rgba(245, 245, 245, 0.06); }
  .pill:hover::before { background: rgba(245, 245, 245, 0.1); }
  .send-active { color: #0a0a0a; }
  .send-active::before { background: #f5f5f5; }
  .send-active:hover::before { background: #ffffff; }
  .spinner { color: #a3a3a3; }
  .menu { background: #1a1a1a; border-color: #303030; }
  .menu-item { color: #f5f5f5; }
  .menu-item:hover { background: rgba(245, 245, 245, 0.06); }
  .menu-item:active { background: rgba(245, 245, 245, 0.09); }
  .menu-icon { color: #a3a3a3; }
  .menu-check { color: #f5f5f5; }
  .menu-chevron { color: #a3a3a3; }
  .menu-brand { color: #f5f5f5; }
  .menu-divider { background: #303030; }
  .menu-label { color: #a3a3a3; }
  .menu-flyout { background: #1a1a1a; border-color: #303030; }
  .menu-popover { background: #1a1a1a; border-color: #303030; }
  .popover-title { color: #f5f5f5; }
  .popover-desc, .popover-meta { color: #a3a3a3; }
  :global(.skill-pill) { background: rgba(43, 127, 255, 0.22); color: #9ec5ff; }
  .slash-menu { background: #1a1a1a; border-color: #303030; }
  .slash-label, .slash-empty { color: #a3a3a3; }
  .menu-item.menu-item-active { background: rgba(245, 245, 245, 0.06); }
  .slash-menu[data-keyboard] .menu-item.menu-item-active,
  .slash-menu[data-keyboard] .menu-item.menu-item-active:hover {
    background: rgba(245, 245, 245, 0.06);
  }
}

@media (prefers-reduced-motion: reduce) {
  .icon-btn::before, .pill::before, .menu-item, .chip-remove, .plus-icon, :global(.skill-pill) { transition: none; }
  .chip, .chip[data-exit], .pill, .pill.pill-exit, .menu, .menu-flyout, .menu-popover, .slash-menu { animation: none; }
  .enhancing-text, .frame[data-enhancing]::after { animation: none; }
  .spinner { animation-duration: 1.4s; }
}
</style>
```
