Site
Agent setup window
Tabbed agent setup instructions for Prompt, MCP, and CLI, with copy actions and an animated prompt preview.
$ npx context-ds add agent-setup-windowUsage
Use it for
Showing how to connect an agent next to a section heading. Wrap it in the ring (rounded-card bg-ring-tint p-2) on white.
Avoid
- More than four methods
- Agent logos recolored to the brand blue
- Toasts from the component itself (pass onCopy)
Dependencies: copy-button, tabs, lucide-react
Example
examples/agent-setup-window.tsx
"use client";import { Bot, MousePointer2, Sparkles } from "lucide-react";import { toast } from "sonner";import { AgentSetupWindow, SetupComment, SetupLine, SetupPrompt, SetupUrl } from "@/components/site/agent-setup-window";const PROMPT_SEGMENTS = [ { text: "Signup for an account & get API key with " }, { text: "context.dev/auth.md", url: true }, { text: ", then follow " }, { text: "docs.context.dev/agent-quickstart", url: true }, { text: " to integrate into the codebase" },];const PROMPT = PROMPT_SEGMENTS.map((s) => s.text).join("");const MCP_URL = "https://mcp.context.dev/mcp";const MCP_COMMAND = `claude mcp add --transport http --scope user context ${MCP_URL}`;const CLI_COMMANDS = ["export CONTEXT_DEV_API_KEY=your_api_key", "context-dev web web-scrape-md --url https://example.com"];export function Site() { return ( <div className="w-full max-w-xl rounded-card bg-ring-tint p-2"> <AgentSetupWindow agentPrompt={PROMPT} // The app's own agent logos go here (an <img> or an svg); these icons stand in for them. agents={[ { name: "Claude Code", icon: <Sparkles /> }, { name: "Codex", icon: <Bot /> }, { name: "Cursor", icon: <MousePointer2 /> }, { name: "Hermes" }, ]} onCopy={({ agent, method }) => toast.success(agent ? `Prompt copied. Paste it into ${agent}.` : method === "prompt" ? "Prompt copied. Paste it into your agent." : "Copied. Run it in your terminal.")} methods={[ { value: "prompt", label: "Prompt", copyText: PROMPT, content: <SetupPrompt segments={PROMPT_SEGMENTS} /> }, { value: "mcp", label: "MCP", copyText: MCP_COMMAND, content: ( <> <SetupComment order={0}># Claude Code</SetupComment> <SetupLine order={1}> claude mcp add --transport http --scope user context <SetupUrl>{MCP_URL}</SetupUrl> </SetupLine> <SetupComment order={2}> # Any other MCP client: add <SetupUrl>{MCP_URL}</SetupUrl> </SetupComment> </> ), }, { value: "cli", label: "CLI", copyText: CLI_COMMANDS.join("\n"), content: ( <> <SetupComment order={0}> # Install: <SetupUrl>docs.context.dev/install-cli</SetupUrl> </SetupComment> {CLI_COMMANDS.map((command, i) => ( <SetupLine key={command} order={i + 1}> {command} </SetupLine> ))} </> ), }, ]} /> </div> );}Source
components/site/agent-setup-window.tsx
"use client";import * as React from "react";import { Check, Copy, Terminal } from "lucide-react";import { useCopy } from "@/components/ui/copy-button";import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";import { cn } from "@/lib/utils";// The home page's agent setup window: the code window's chrome with the setup methods (Prompt, MCP, CLI) as small tabs,// a row of agent logos that copy the prompt for that agent, and one copy button for what is showing. Panels crossfade// in one grid cell, so the window keeps the tallest panel's height and never jumps; the prompt's words fade in one by// one each time its tab is picked. While the window is on screen it moves to the next method every six seconds,// pausing while the pointer or focus is inside it; reduced motion turns both the cycling and the reveal off.export type AgentSetupMethod = { value: string; label: string; copyText: string; content: React.ReactNode };export type SetupAgent = { name: string; icon?: React.ReactNode };export type AgentSetupCopy = { method?: string; agent?: string; text: string };const CYCLE_MS = 6000;const WORD_STAGGER_MS = 30;const LINE_STAGGER_MS = 120;// Opacity only: a transform would need inline-block words, which stops a long URL wrapping at its hyphen. The site// plays this at 400ms with a 4px blur; it is held to 300ms here. `backwards` holds each word hidden through its delay,// and a paused animation keeps it hidden until the window is first seen.const revealClass = "animate-fade-in [animation-duration:var(--ds-duration-slow)] motion-reduce:animate-none";const RevealContext = React.createContext(true);function useRevealStyle(order: number, staggerMs: number): React.CSSProperties { const playing = React.useContext(RevealContext); return { animationDelay: `${order * staggerMs}ms`, animationFillMode: "backwards", animationPlayState: playing ? "running" : "paused" };}const chromeButtonClass = "inline-flex size-7 shrink-0 items-center justify-center rounded-card outline-none transition-colors duration-150 ease-out hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/60 motion-reduce:transition-none";/** A link-like run inside the window: the one accent color, faintly underlined. */function SetupUrl({ children, className, style }: { children: React.ReactNode; className?: string; style?: React.CSSProperties }) { return ( <span style={style} className={cn("text-blue-60 underline decoration-white/20 underline-offset-2", className)}> {children} </span> );}/** A faded shell comment on its own line. */function SetupComment({ children, order = 0 }: { children: React.ReactNode; order?: number }) { return ( <span style={useRevealStyle(order, LINE_STAGGER_MS)} className={cn("block text-white/35", revealClass)}> {children} </span> );}/** A command on its own line, revealed after the lines before it. */function SetupLine({ children, order = 0 }: { children: React.ReactNode; order?: number }) { return ( <span style={useRevealStyle(order, LINE_STAGGER_MS)} className={cn("block", revealClass)}> {children} </span> );}/** The prompt, word by word; segments marked `url` arrive whole so their highlight never shows half drawn. */function SetupPrompt({ segments }: { segments: { text: string; url?: boolean }[] }) { const tokens = segments.flatMap((segment, s) => (segment.url ? [{ key: `${s}`, text: segment.text, url: true }] : segment.text.split(/(?<=\s)/).map((word, w) => ({ key: `${s}-${w}`, text: word, url: false })))); return ( <p> {tokens.map((token, order) => ( <PromptToken key={token.key} text={token.text} url={token.url} order={order} /> ))} </p> );}function PromptToken({ text, url, order }: { text: string; url: boolean; order: number }) { const style = useRevealStyle(order, WORD_STAGGER_MS); return url ? ( <SetupUrl style={style} className={revealClass}> {text} </SetupUrl> ) : ( <span style={style} className={revealClass}> {text} </span> );}function AgentSetupWindow({ methods, agents = [], agentPrompt, defaultValue, cycle = true, onCopy, className,}: { methods: AgentSetupMethod[]; /** Agents offered in the chrome bar; picking one copies `agentPrompt` (or the first method's text). */ agents?: SetupAgent[]; agentPrompt?: string; defaultValue?: string; /** Moves to the next method every six seconds while the window is on screen. */ cycle?: boolean; /** Called after a copy, so the app can show its own toast. */ onCopy?: (copied: AgentSetupCopy) => void; className?: string;}) { const [value, setValue] = React.useState(defaultValue ?? methods[0]?.value ?? ""); // Bumped on every pick, even of the tab already showing, so its timer and reveal start over. const [pick, setPick] = React.useState(0); const { hasCopied, copy } = useCopy(); const active = methods.find((m) => m.value === value) ?? methods[0]; const ref = React.useRef<HTMLDivElement>(null); const [inView, setInView] = React.useState(false); const [seen, setSeen] = React.useState(false); const [hovered, setHovered] = React.useState(false); const [focusWithin, setFocusWithin] = React.useState(false); const [reducedMotion, setReducedMotion] = React.useState(false); React.useEffect(() => { const node = ref.current; if (!node) return; const observer = new IntersectionObserver( ([entry]) => { setInView(entry.isIntersecting); if (entry.isIntersecting) setSeen(true); }, { threshold: 0.4 }, ); observer.observe(node); const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const onChange = () => setReducedMotion(query.matches); onChange(); query.addEventListener("change", onChange); return () => { observer.disconnect(); query.removeEventListener("change", onChange); }; }, []); // The time a method has already been on screen, so a pause (hover, focus, scrolling away) resumes rather than restarts. const shown = React.useRef({ value, pick, ms: 0 }); const running = cycle && methods.length > 1 && inView && !hovered && !focusWithin && !reducedMotion; React.useEffect(() => { if (!running) return; if (shown.current.value !== value || shown.current.pick !== pick) shown.current = { value, pick, ms: 0 }; const current = shown.current; const startedAt = performance.now(); const timer = window.setTimeout(() => { const index = methods.findIndex((m) => m.value === value); setValue(methods[(index + 1) % methods.length].value); }, CYCLE_MS - current.ms); return () => { window.clearTimeout(timer); current.ms += performance.now() - startedAt; }; }, [running, value, pick, methods]); const copyActive = async () => { if (!active) return; await copy(active.copyText); onCopy?.({ method: active.value, text: active.copyText }); }; const copyForAgent = async (agent: SetupAgent) => { const text = agentPrompt ?? methods[0]?.copyText ?? ""; try { await navigator.clipboard.writeText(text); } catch { return; } onCopy?.({ agent: agent.name, text }); }; return ( <Tabs ref={ref} value={value} onValueChange={(next) => { setValue(next); setPick((count) => count + 1); }} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} onFocus={() => setFocusWithin(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocusWithin(false); }} data-slot="agent-setup-window" className={cn( "gap-0 overflow-hidden rounded-card bg-code text-left text-white shadow-[0_var(--ds-space-2)_var(--ds-space-10)_color-mix(in_srgb,var(--ds-color-black)_18%,transparent)] ring-1 ring-white/10", className, )} > <div className="flex items-center gap-2 border-b border-white/10 px-4 py-2"> <span aria-hidden="true" className="size-3 shrink-0 rounded-pill bg-window-close" /> <span aria-hidden="true" className="size-3 shrink-0 rounded-pill bg-window-minimize" /> <span aria-hidden="true" className="size-3 shrink-0 rounded-pill bg-window-zoom" /> <TabsList glide={false} aria-label="Agent setup method" className="ml-2 h-auto gap-0.5 bg-transparent p-0"> {methods.map((method) => ( <TabsTrigger key={method.value} value={method.value} className="flex h-auto items-center gap-1.5 rounded-card px-2.5 py-1 text-caption font-medium text-white/45 shadow-none transition-colors duration-200 ease-out hover:text-white/80 focus-visible:ring-white/60 data-[state=active]:bg-white/10 data-[state=active]:text-white data-[state=active]:shadow-none" > {method.label} </TabsTrigger> ))} </TabsList> {agents.length > 0 && ( <div className="ml-auto hidden items-center gap-0.5 border-r border-white/10 pr-1.5 sm:flex"> {agents.map((agent) => ( <button key={agent.name} type="button" onClick={() => copyForAgent(agent)} aria-label={`Copy the setup prompt for ${agent.name}`} title={agent.name} className={cn(chromeButtonClass, "group/agent")} > {/* The logo presses in a step when picked. */} <span className="flex text-white/60 transition-transform duration-200 ease-out group-active/agent:scale-90 motion-reduce:transition-none [&_img]:size-4.5 [&_svg]:size-4.5"> {agent.icon ?? <Terminal aria-hidden="true" />} </span> </button> ))} </div> )} <button type="button" onClick={copyActive} aria-label={hasCopied ? "Copied" : `Copy the ${active?.label} setup`} className={cn(chromeButtonClass, "ml-auto text-white/45 hover:text-white [&_svg]:size-3.5", agents.length > 0 && "sm:ml-1")} > {hasCopied ? <Check aria-hidden="true" strokeWidth={1.75} /> : <Copy aria-hidden="true" strokeWidth={1.5} />} </button> </div> <RevealContext.Provider value={seen}> {/* Every panel shares one grid cell, so the window keeps the tallest panel's height and never jumps between tabs. */} <div className="grid p-5 font-mono text-caption leading-code break-words text-white/85 sm:text-body-sm"> {methods.map((method) => ( <TabsContent key={method.value} value={method.value} forceMount className="col-start-1 row-start-1 mt-0 transition-[opacity,filter,visibility] duration-200 ease-out data-[state=inactive]:invisible data-[state=inactive]:opacity-0 data-[state=inactive]:blur-[2px] motion-reduce:transition-none" > {/* Keyed on the pick, so the panel that is showing replays its reveal. */} <div key={method.value === value ? `on-${pick}` : "off"}>{method.content}</div> </TabsContent> ))} </div> </RevealContext.Provider> </Tabs> );}export { AgentSetupWindow, SetupPrompt, SetupUrl, SetupComment, SetupLine };Reference: src/components/sections/home/agent-setup-window.tsx; src/components/sections/shared/agent-setup-prompt.ts.