diff --git a/app/SkillPageClient.tsx b/app/SkillPageClient.tsx index 0f16e1d..6d98914 100644 --- a/app/SkillPageClient.tsx +++ b/app/SkillPageClient.tsx @@ -10,6 +10,7 @@ import { MaskedHeading } from "@/components/motion/masked-heading"; import { DecryptReveal } from "@/components/motion/decrypt-reveal"; import BendingMarquee from "@/components/motion/bending-marquee"; import { ParticleScroll } from "@/components/motion/particle-scroll"; +import { SkillsTransitionLink } from "@/components/motion/skills-transition-link"; import { ScrambledInstallCommand, type PkgManager } from "@/components/motion/scrambled-install-command"; import { type RealSkill, CATEGORY_LABELS } from "@/lib/skill-types"; import { @@ -382,6 +383,17 @@ function Nav({ {repoMeta.stars !== null ? formatStarCount(repoMeta.stars) : "—"} + + Browse Skills + + navigateToSection(e, "quickstart")} @@ -1233,13 +1245,13 @@ function SkillCatalog({

- Every skill is read live from skills/<id>/SKILL.md — name, description, and version straight from the repo. + Every skill is read straight from skills/<id>/SKILL.md when the site is built — name, description, and version out of the repo, not a hand-kept copy.

08.0 Skill Catalog → - + Browse every file → - +
diff --git a/app/globals.css b/app/globals.css index 391a61a..0628901 100644 --- a/app/globals.css +++ b/app/globals.css @@ -151,3 +151,125 @@ -webkit-font-smoothing: antialiased; } + +/* Glass surfaces: card backgrounds that let the animated background show + through. Defined once here so the whole dashboard is tuned from one place + — raise the percentage to calm the background down, lower it to let more + through. They resolve against whichever --bg-* the active theme sets, so a + single definition covers light and dark. Tailwind v3 cannot apply an + opacity modifier to a var colour, which is why these exist at all rather + than a `/72` suffix at each call site. */ +:root { + /* Fallbacks for anything rendered outside a LiquefyProvider. */ + --glass-surface: color-mix(in srgb, var(--bg-surface) 38%, transparent); + --glass-frame: color-mix(in srgb, var(--bg-frame) 30%, transparent); + --glass-elevated: color-mix(in srgb, var(--bg-elevated) 40%, transparent); + --glass-line: var(--border-subtle); + --glass-blur: 12px; +} + +/* Declared on the provider rather than :root on purpose: a custom property is + substituted at computed-value time on the element that declares it, and + --lq-* only exists from .lq-provider down. Declaring these at :root + resolved --lq-glass-soft to its fallback before any card could inherit it, + so Liquefy's theme and tint had no effect on the surfaces. */ +.lq-provider { + --glass-surface: color-mix(in srgb, var(--bg-surface) 38%, var(--lq-glass-soft)); + --glass-frame: color-mix(in srgb, var(--bg-frame) 30%, var(--lq-glass-soft)); + --glass-elevated: color-mix(in srgb, var(--bg-elevated) 40%, var(--lq-glass-soft)); + --glass-line: var(--lq-line, var(--border-subtle)); + --glass-blur: var(--lq-blur, 12px); +} + +/* The /skills shell reads long prose and code, so its panels sit much closer + to opaque than the marketing pages' glass does. Declared after .lq-provider + so it wins on source order at equal specificity, and scoped to the shell so + the rest of the site keeps the translucent look. */ +.skills-shell { + --glass-surface: color-mix(in srgb, var(--bg-surface) 88%, transparent); + --glass-frame: color-mix(in srgb, var(--bg-frame) 90%, transparent); + --glass-elevated: color-mix(in srgb, var(--bg-elevated) 88%, transparent); +} + +/* The workspace is the one surface people read and type into for minutes at a + time, so it opts out of translucency entirely: nested translucent panels + over a moving background is what makes long reading tiring. Declared as + token overrides so every nested surface inherits it without each component + having to know it is inside a workspace. */ +.skills-workspace { + --glass-surface: var(--bg-surface); + --glass-frame: var(--bg-frame); + --glass-elevated: var(--bg-elevated); +} + +/* Measured, not guessed: on this shell's card surfaces the shared dark + --text-tertiary comes out at 3.67:1, under the 4.5:1 WCAG AA asks for at + these sizes, and here it carries real information — trigger phrases, + categories, the repository snapshot. Brightened locally to 4.76:1, which + keeps the hierarchy intact (primary 14.5, secondary 6.8, tertiary 4.8) and + leaves the rest of the site's text alone, the way .intro-panel-text + already does for the marketing sections. */ +.skills-shell { + /* #82AAFF is the site's accent and reads well on dark, but as *text* on a + light surface it measures 1.96:1 — unreadable, and it was carrying the + card's primary action. Light mode gets a darker blue of the same family; + tints and filled buttons keep the original hue, since those are + backgrounds rather than text. */ + --accent: #2F63C7; + --text-tertiary: #5C6675; +} + +.skills-shell { + /* The graph's colours are meaning, not decoration: a node's fill says + whether it is the skill you pinned, and an edge's stroke says which + direction the reference runs. WCAG asks meaningful non-text graphics for + ~3:1, and the dark palette's #82AAFF/#F472B6 sit near 1.9:1 on a light + card. Semantic tokens per theme, so the graph reads in both. */ + --skill-node: #2F63C7; + --skill-node-active: #B4126B; + --skill-edge: #94A3B8; + --skill-edge-out: #2F63C7; + --skill-edge-in: #B4126B; +} + +.dark .skills-shell { + --accent: #82AAFF; + --text-tertiary: #7C8593; + --text-disabled: #6E7684; + --skill-node: #82AAFF; + --skill-node-active: #F472B6; + --skill-edge: var(--border-strong); + --skill-edge-out: #82AAFF; + --skill-edge-in: #F472B6; +} + +/* Scrollbars: the browser default renders as a bright white bar against the + dark surfaces, which reads as a UI element rather than chrome. Tint it to + the existing border token so it recedes, in both themes. */ +@layer base { + * { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track, + ::-webkit-scrollbar-corner { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: var(--border-strong); + border: 3px solid transparent; + background-clip: content-box; + border-radius: 9999px; + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var(--text-disabled); + } +} diff --git a/app/skills/[skillId]/page.tsx b/app/skills/[skillId]/page.tsx new file mode 100644 index 0000000..65db1d0 --- /dev/null +++ b/app/skills/[skillId]/page.tsx @@ -0,0 +1,73 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ChevronRight } from "lucide-react"; +import { getSkillCatalog, getSkillDocument } from "@/lib/skill-catalog"; +import { getRepoSnapshot } from "@/lib/repo-snapshot"; +import { SkillDetail } from "@/components/skills/skill-detail"; +import { MarkdownPreview } from "@/components/skills/markdown-preview"; +import { RepoSnapshotChip } from "@/components/skills/repo-snapshot-chip"; +import { RememberSkill } from "@/components/skills/remember-skill"; + +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return getSkillCatalog().map((skill) => ({ skillId: skill.id })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ skillId: string }>; +}): Promise { + const { skillId } = await params; + const skill = getSkillCatalog().find((s) => s.id === skillId); + if (!skill) return {}; + return { + title: `${skill.handle} · AI DevKit Skills`, + description: skill.summary || skill.description, + }; +} + +export default async function SkillDetailPage({ params }: { params: Promise<{ skillId: string }> }) { + const { skillId } = await params; + const skills = getSkillCatalog(); + const skill = skills.find((s) => s.id === skillId); + if (!skill) notFound(); + + const handles = Object.fromEntries(skills.map((s) => [s.id, s.handle])); + const document = getSkillDocument(skill.id); + + return ( +
+ + +
+
+ + +
+ + : null + } + /> +
+
+ ); +} diff --git a/app/skills/[skillId]/workspace/page.tsx b/app/skills/[skillId]/workspace/page.tsx new file mode 100644 index 0000000..31523e2 --- /dev/null +++ b/app/skills/[skillId]/workspace/page.tsx @@ -0,0 +1,90 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ChevronRight, ExternalLink } from "lucide-react"; +import { getSkillCatalog } from "@/lib/skill-catalog"; +import { getSkillTreeNodes } from "@/lib/skills-tree"; +import { REPO_SKILLS_TREE } from "@/lib/repo-links"; +import SkillTreeBrowser from "@/components/skills/skill-tree-browser"; +import { RememberSkill } from "@/components/skills/remember-skill"; + +export const dynamic = "force-static"; +export const dynamicParams = false; + +export function generateStaticParams() { + return getSkillCatalog().map((skill) => ({ skillId: skill.id })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ skillId: string }>; +}): Promise { + const { skillId } = await params; + const skill = getSkillCatalog().find((s) => s.id === skillId); + if (!skill) return {}; + return { + title: `${skill.handle} workspace · AI DevKit Skills`, + description: `Read and mark up every file in ${skill.handle} — SKILL.md, references, and scripts.`, + }; +} + +export default async function SkillWorkspacePage({ params }: { params: Promise<{ skillId: string }> }) { + const { skillId } = await params; + const skill = getSkillCatalog().find((s) => s.id === skillId); + const tree = getSkillTreeNodes(skillId); + if (!skill || !tree) notFound(); + + return ( +
+
+
+ +
+

{skill.handle}

+

+ {skill.summary || skill.description} +

+
+

+ {skill.files.length} files · read, mark up locally, copy out. Edits stay in this browser. +

+
+ +
+ + + Skill overview + + + + GitHub + +
+
+ +
+ + +
+
+ ); +} diff --git a/app/skills/layout.tsx b/app/skills/layout.tsx new file mode 100644 index 0000000..07a7182 --- /dev/null +++ b/app/skills/layout.tsx @@ -0,0 +1,71 @@ +import Link from "next/link"; +import { Terminal, ArrowLeft } from "lucide-react"; +import { + SkillsSidebarNav, + SkillsSectionTitle, +} from "@/components/skills/skills-sidebar-nav"; +import { SkillsIconRail } from "@/components/skills/skills-icon-rail"; +import { SkillsBackdrop } from "@/components/skills/skills-backdrop"; +import { ThemeToggle } from "@/components/skills/theme-toggle"; +import { LiquefyTheme } from "@/components/skills/liquefy-theme"; + +export default function SkillsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ + + {/* z-20, above the content column's z-10: backdrop-filter makes this + aside a stacking context, so the rail's hover tooltips cannot escape + it on z-index alone. At equal z the later sibling wins and the + content panel painted over them, leaving a sliver at the rail edge. */} + + +
+
+
+ +
+ +
+ + ai-devkit + + +
+ ai-devkit + | + + + +
+
+ + + + Back to catalog + +
+
+ +
+ +
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/app/skills/not-found.tsx b/app/skills/not-found.tsx new file mode 100644 index 0000000..ec48559 --- /dev/null +++ b/app/skills/not-found.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +// Segment-local so it renders inside app/skills/layout.tsx's sidebar shell +// (a not-found.tsx only bypasses layouts BELOW it in the tree) — the two +// stub routes should read as "not built yet" inside the dashboard, not +// bounce out to Next's generic default 404 page. +export default function SkillsNotFound() { + return ( +
+
+

404

+

Not built yet

+

+ This section doesn't have anything here yet — check back once it's scoped out. +

+ + Browse all skills + +
+
+ ); +} diff --git a/app/skills/page.tsx b/app/skills/page.tsx index eddf0d4..3930875 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -1,60 +1,78 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { Terminal, ArrowLeft } from "lucide-react"; -import { getSkillsTree } from "@/lib/skills-tree"; -import SkillTreeBrowser from "@/components/skills/skill-tree-browser"; +import { FileText, Layers } from "lucide-react"; +import { getCatalogCategories, getSkillCatalog } from "@/lib/skill-catalog"; +import { getRepoSnapshot } from "@/lib/repo-snapshot"; +import { REPO_SKILLS_TREE } from "@/lib/repo-links"; +import { SkillCatalog } from "@/components/skills/skill-catalog"; +import { RepoSnapshotChip } from "@/components/skills/repo-snapshot-chip"; +import { GooeyTextReveal } from "@/components/motion/gooey-text-reveal"; export const dynamic = "force-static"; export const metadata: Metadata = { title: "Browse Skills · AI DevKit Skills", - description: "Every file under skills/ in CommandOSSLabs/ai-devkit, read live from the repository — SKILL.md, references, and scripts.", + description: + "Every skill in CommandOSSLabs/ai-devkit — what it does, when to use it, and which skills it works with. Generated from the repository at build time.", }; +const metaChipClassName = + "flex h-9 items-center gap-1.5 rounded-lg border border-[var(--border-subtle)] bg-[var(--glass-elevated)] px-3 text-[12.5px] text-[var(--text-secondary)]"; + export default function SkillsBrowsePage() { - const tree = getSkillsTree(); + const skills = getSkillCatalog(); + const categories = getCatalogCategories(skills); + const snapshot = getRepoSnapshot(); + const fileCount = skills.reduce((n, s) => n + s.files.length, 0); + const handles = Object.fromEntries(skills.map((s) => [s.id, s.handle])); return ( -
-
-
- -
- -
- ai-devkit - - - - Back to catalog - -
-
- -
-
-

- Browse skills -

-

- Every file under skills/ in{" "} +

+
+ {/* basis + flex-1 rather than shrink-to-fit: GooeyTextReveal re-splits + its text whenever its own width changes, so a container sized BY that + text re-measures on every split and the reveal restarts forever, + leaving the heading permanently blurred out. Sizing the column from + the row instead makes the width independent of the split. */} +
+ +

Browse skills

+

+ Find the right skill for the task you are working on. Every skill in{" "} CommandOSSLabs/ai-devkit - , read live from the repository — click a file to read it, not just its name and size. + : what it does, when to reach for it, and what it works with. Open one to read it, or jump straight into its + files.

+
+
+ +
+
+
+
+
+
+
- -
+
); } diff --git a/app/skills/playground/page.tsx b/app/skills/playground/page.tsx new file mode 100644 index 0000000..c624ccb --- /dev/null +++ b/app/skills/playground/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +// The playground was never built. A 404 behind a nav item is a broken promise +// and a "coming soon" page is a surface with nothing in it, so an old link +// lands on the catalog instead. +export default function PlaygroundPage() { + redirect("/skills"); +} diff --git a/app/skills/prompt-inputs/page.tsx b/app/skills/prompt-inputs/page.tsx new file mode 100644 index 0000000..b5fe42f --- /dev/null +++ b/app/skills/prompt-inputs/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { PromptInputDemo } from "@/components/agents/prompt-input-demo"; +import { getSkillExamples } from "@/lib/skill-examples"; + +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: "Prompt Inputs · AI DevKit Skills", + description: "An auto-growing agent composer with prompt actions, model selection, keyboard submission, and animated send and stop states.", +}; + +export default function PromptInputsPage() { + const skillExamples = getSkillExamples(); + + return ( +
+
+
+

Prompt Inputs

+

+ An auto-growing agent composer with prompt actions, model selection, keyboard submission, and animated send and stop states. +

+
+ + Interactive demo + +
+ +
+ +
+
+ ); +} diff --git a/app/skills/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx new file mode 100644 index 0000000..0a4de63 --- /dev/null +++ b/app/skills/visualize-interactions/page.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from "next"; +import { Share2, Link2, CircleDot } from "lucide-react"; +import { getSkillGraph } from "@/lib/skill-graph"; +import { SkillGraphView } from "@/components/skills/skill-graph-view"; +import { BlurHighlight } from "@/components/ui/blur-highlight"; + +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: "Visualize interactions · AI DevKit Skills", + description: + "How the skills in CommandOSSLabs/ai-devkit reference each other — a graph built from the cmk: handles in every SKILL.md.", +}; + +const metaChipClassName = + "flex h-9 items-center gap-1.5 rounded-lg border border-[var(--border-subtle)] bg-[var(--glass-elevated)] px-3 text-[12.5px] text-[var(--text-secondary)] backdrop-blur-sm"; + +export default function VisualizeInteractionsPage() { + const graph = getSkillGraph(); + const entryPoints = graph.nodes.filter((n) => n.inDegree === 0).length; + + return ( +
+
+
+

Visualize interactions

+ + Skills reference each other by their cmk: handle, and the result is hub-and-spoke rather than a flat list. + +
+ +
+
+ + {graph.nodes.length} skills +
+
+ + {graph.edges.length} references +
+
+ + {entryPoints} entry points +
+
+
+ + +
+ ); +} diff --git a/app/skills/workspace/page.tsx b/app/skills/workspace/page.tsx new file mode 100644 index 0000000..2698f2c --- /dev/null +++ b/app/skills/workspace/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { getSkillCatalog } from "@/lib/skill-catalog"; +import { WorkspaceEntry } from "@/components/skills/workspace-entry"; + +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: "Skill workspace · AI DevKit Skills", + description: "Open a skill's files in the full workspace: preview, source, and local drafts.", +}; + +// The shareable workspace address. It resolves to the per-skill workspace +// rather than rendering one itself, because rendering here would mean shipping +// every skill's file contents (455KB of markdown) to anyone who opens the +// route, when a visitor only ever wants one skill's files. +export default function WorkspaceEntryPage() { + return skill.id)} />; +} diff --git a/components/agents/prompt-input-demo.tsx b/components/agents/prompt-input-demo.tsx new file mode 100644 index 0000000..b608ae1 --- /dev/null +++ b/components/agents/prompt-input-demo.tsx @@ -0,0 +1,229 @@ +"use client"; + +import Link from "next/link"; +import { ArrowUpRight, Cpu, FileText, ImagePlus, Puzzle, Rocket, Sparkles, Wind, X, Zap } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import type { SkillExample } from "@/lib/skill-examples"; +import { normalizeSkillId } from "@/lib/skill-id"; +import { PromptInput, type PromptAction, type PromptModel } from "./prompt-input"; + +// Adapted from the component doc's usage example. That example fetches +// real provider favicons for each model icon via an external service — +// dropped here since it's a live network dependency unrelated to what this +// tab demonstrates, replaced with plain icons. +// +// The "Use a skill" action is the actual point of this tab: rather than a +// generic notice, it opens a picker built from skillExamples (read server- +// side straight off this repo's skills/*/SKILL.md frontmatter — see +// lib/skill-examples.ts) so picking a skill inserts the REAL trigger +// phrase its own description advertises, e.g. cmk-adr's "record this +// decision". That's the concrete example of "what prompt makes a model +// reach for this skill" — not invented copy. + +const MODELS: PromptModel[] = [ + { value: "claude-sonnet-5", label: "Claude Sonnet 5", icon: }, + { value: "gpt-5.2", label: "GPT-5.2", icon: }, + { value: "gemini-3.6-flash", label: "Gemini 3.6 Flash", icon: }, + { value: "grok-4.5", label: "Grok 4.5", icon: }, + { value: "mistral-large-3", label: "Mistral Large 3", icon: }, +]; + +const ACTIONS: PromptAction[] = [ + { + value: "image", + label: "Attach image", + description: "Add a screenshot or visual reference.", + icon: , + }, + { + value: "skill", + label: "Use a skill", + description: "Insert a real trigger phrase from this repo's skills.", + icon: , + }, + { + value: "context", + label: "Add context", + description: "Include a file with supporting details.", + icon: , + }, +]; + +const DEFAULT_VALUE = "Review the current implementation and suggest the next improvement."; + +export function PromptInputDemo({ skillExamples }: { skillExamples: SkillExample[] }) { + const reduce = useReducedMotion() ?? false; + const timer = useRef(undefined); + const [value, setValue] = useState(DEFAULT_VALUE); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(); + const [notice, setNotice] = useState(); + const [pickerOpen, setPickerOpen] = useState(false); + const [context, setContext] = useState(null); + + // Arriving from a skill's page (…/prompt-inputs?skill=adr) should land you + // in that skill's context with its own trigger phrase already typed, rather + // than on a blank composer you have to re-find the skill in. + useEffect(() => { + const requested = normalizeSkillId(new URLSearchParams(window.location.search).get("skill")); + if (!requested) return; + const skill = skillExamples.find((s) => s.id === requested); + if (!skill) return; + setContext(skill); + if (skill.examples[0]) setValue(skill.examples[0]); + }, [skillExamples]); + + useEffect( + () => () => { + if (timer.current) window.clearTimeout(timer.current); + }, + [], + ); + + const submit = (prompt: string) => { + setSent(undefined); + setNotice(undefined); + setLoading(true); + timer.current = window.setTimeout(() => { + setLoading(false); + setSent(prompt); + setValue(""); + }, 900); + }; + + const stop = () => { + if (timer.current) window.clearTimeout(timer.current); + setLoading(false); + }; + + const pickExample = (skill: SkillExample, example: string) => { + setValue(example); + setContext(skill); + setPickerOpen(false); + setSent(undefined); + setNotice(`Inserted ${skill.label}'s own trigger phrase — this is what tells a model to reach for it.`); + }; + + return ( +
+ {context && ( +
+
+ )} + + { + if (action === "skill") { + setPickerOpen((v) => !v); + return; + } + const selected = ACTIONS.find((item) => item.value === action); + setNotice(selected ? `${selected.label} selected.` : undefined); + setSent(undefined); + }} + /> + + + {pickerOpen && ( + +
+

+ Real trigger phrases from skills/*/SKILL.md +

+ +
+
+ {skillExamples.map((skill) => ( +
+

{skill.label}

+
+ {skill.examples.map((example) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ +

+ Demo composer — nothing is sent to a model. +

+ +
+ + {sent || notice ? ( + + {sent ? "Prompt captured. No request left the browser." : notice} + + ) : null} + +
+
+ ); +} + +export default PromptInputDemo; diff --git a/components/agents/prompt-input.tsx b/components/agents/prompt-input.tsx new file mode 100644 index 0000000..8311504 --- /dev/null +++ b/components/agents/prompt-input.tsx @@ -0,0 +1,322 @@ +"use client"; + +import * as React from "react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { Bot, Check, ChevronDown, Plus, Send, Square } from "lucide-react"; + +// Hand-built from a component doc (props table + a consumer usage example), +// not a full source dump like most other adapted references this session — +// `npx shadcn add @beui/prompt-input` needs a components.json this repo +// doesn't have and won't get (same call made for the earlier Watermelon +// registry attempt), so this implements the documented API/behavior +// directly against this project's own design tokens. + +export type PromptModel = { value: string; label: string; icon?: React.ReactNode }; +export type PromptAction = { value: string; label: string; description?: string; icon?: React.ReactNode }; + +export interface PromptInputProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + models?: PromptModel[]; + model?: string; + defaultModel?: string; + onModelChange?: (model: string) => void; + actions?: PromptAction[]; + onAction?: (action: string) => void; + onSubmit?: (value: string, model?: string) => void | Promise; + loading?: boolean; + onStop?: () => void; + minRows?: number; + maxRows?: number; + leadingAction?: React.ReactNode; + className?: string; +} + +const LINE_HEIGHT_PX = 20; + +function useOutsideClick(ref: React.RefObject, onOutside: () => void, active: boolean) { + React.useEffect(() => { + if (!active) return; + const onPointerDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onOutside(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onOutside(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [ref, onOutside, active]); +} + +function ActionsMenu({ actions, onAction }: { actions: PromptAction[]; onAction?: (action: string) => void }) { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + useOutsideClick(ref, () => setOpen(false), open); + + if (actions.length === 0) return null; + + return ( +
+ + + {open && ( + + {actions.map((a) => ( + + ))} + + )} + +
+ ); +} + +function ModelSelect({ + models, + model, + onChange, +}: { + models: PromptModel[]; + model?: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + useOutsideClick(ref, () => setOpen(false), open); + + if (models.length === 0) return null; + const active = models.find((m) => m.value === model) ?? models[0]; + + return ( +
+ + + {open && ( + + {models.map((m) => { + const selected = m.value === active?.value; + return ( + + ); + })} + + )} + +
+ ); +} + +function SendButton({ + loading, + disabled, + onSend, + onStop, + reduceMotion, +}: { + loading: boolean; + disabled: boolean; + onSend: () => void; + onStop?: () => void; + reduceMotion: boolean | null; +}) { + return ( + + ); +} + +export function PromptInput({ + value, + defaultValue = "", + onValueChange, + models = [], + model, + defaultModel, + onModelChange, + actions = [], + onAction, + onSubmit, + loading = false, + onStop, + minRows = 2, + maxRows = 8, + leadingAction, + className = "", +}: PromptInputProps) { + const reduceMotion = useReducedMotion(); + const textareaRef = React.useRef(null); + + const [internalValue, setInternalValue] = React.useState(defaultValue); + const isValueControlled = value !== undefined; + const currentValue = isValueControlled ? value : internalValue; + + const [internalModel, setInternalModel] = React.useState(defaultModel ?? models[0]?.value); + const isModelControlled = model !== undefined; + const currentModel = isModelControlled ? model : internalModel; + + const resize = React.useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + const min = minRows * LINE_HEIGHT_PX; + const max = maxRows * LINE_HEIGHT_PX; + el.style.height = `${Math.min(Math.max(el.scrollHeight, min), max)}px`; + }, [minRows, maxRows]); + + React.useEffect(() => { + resize(); + }, [currentValue, resize]); + + const setValue = (next: string) => { + if (!isValueControlled) setInternalValue(next); + onValueChange?.(next); + }; + + const setModel = (next: string) => { + if (!isModelControlled) setInternalModel(next); + onModelChange?.(next); + }; + + const submit = () => { + const trimmed = currentValue.trim(); + if (!trimmed || loading) return; + onSubmit?.(trimmed, currentModel); + if (!isValueControlled) setInternalValue(""); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }; + + return ( +
+