import * as React from "react"; import { motion, AnimatePresence, useInView, useScroll, useTransform, type Transition, } from "framer-motion"; // Shared motion presets — hoisted so every JSX site receives the same object // reference and doesn't trigger jsx-no-new-object-as-prop. const FADE_IN_UP = { opacity: 0, y: 20 }; const FADE_IN = { opacity: 1, y: 0 }; const FADE_IN_UP_TINY = { opacity: 0, y: -10 }; const FADE_IN_UP_XL = { opacity: 0, y: 30 }; const FADE_IN_UP_40 = { opacity: 0, y: 40 }; const FADE_IN_UP_4 = { opacity: 0, y: 4 }; const FADE_OUT_UP_4 = { opacity: 0, y: 4 }; const EASE_OUT_06: Transition = { duration: 0.6, ease: "easeOut" }; const EASE_OUT_06_DELAY_015: Transition = { duration: 0.6, delay: 0.15, ease: "easeOut" }; const EASE_OUT_06_DELAY_01: Transition = { duration: 0.6, delay: 0.1, ease: "easeOut" }; const EASE_OUT_06_DELAY_04: Transition = { duration: 0.6, delay: 0.4, ease: "easeOut" }; const EASE_OUT_08_DELAY_05: Transition = { duration: 0.8, delay: 0.5, ease: "easeOut" }; const EASE_OUT_05: Transition = { duration: 0.5, ease: "easeOut" }; const EASE_OUT_015: Transition = { duration: 0.15, ease: "easeOut" }; const DURATION_05: Transition = { duration: 0.5 }; const VIEWPORT_60 = { once: true, margin: "-60px" }; const SVG_OVERFLOW_VISIBLE_STYLE = { overflow: "visible" as const }; const PHONE_PERSPECTIVE_STYLE = { minHeight: 480, perspective: 1200 }; import { CursorFieldProvider } from "~/components/butterfly"; import { CommandDialog } from "~/components/command-dialog"; import { appStoreUrl, playStoreUrl, webAppUrl, getDownloadOptions, useDetectedPlatform, AppleIcon, AndroidIcon, TerminalIcon, GlobeIcon, } from "~/downloads"; import { useRelease } from "~/routes/__root"; import { Mic } from "lucide-react"; import { HeroMockup } from "~/components/hero-mockup"; import { ClaudeIcon } from "~/components/mockup"; import { SiteHeader } from "~/components/site-header"; import "~/styles.css"; interface LandingPageProps { title: React.ReactNode; subtitle: string; } export function LandingPage({ title, subtitle }: LandingPageProps) { return ( {/* Hero section with background image */}
{/* Mockup - inside hero so it's above the gradient, positioned to overflow into black section */}
{/* Phone showcase */} {/* Content section */}
); } function Nav() { return ( ); } function Hero({ title, subtitle }: { title: React.ReactNode; subtitle: string }) { return (
{title} {subtitle}
); } const CLAUDE_CODE_BADGE_ICON = ; const CODEX_BADGE_ICON = ; const OPENCODE_BADGE_ICON = ; const COPILOT_BADGE_ICON = ; const PI_BADGE_ICON = ; function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) { const [hovered, setHovered] = React.useState(false); const handleMouseEnter = React.useCallback(() => setHovered(true), []); const handleMouseLeave = React.useCallback(() => setHovered(false), []); return ( {icon} {hovered && ( {name} )} ); } function FeatureSection({ title, description, children, }: { title: string; description: string; children: React.ReactNode; }) { return (

{title}

{description}

{children}
); } function MultiProviderSection() { const providers = [ { name: "Claude Code", icon: }, { name: "Codex", icon: }, { name: "OpenCode", icon: }, { name: "Copilot", icon: }, { name: "Pi", icon: }, ]; return (
{providers.slice(0, 3).map((p) => (
{p.icon} {p.name}
))}
{providers.slice(3).map((p) => (
{p.icon} {p.name}
))}
); } function SelfHostedDiagram() { const clients = [ { name: "Desktop", icon: ( ), }, { name: "Web", icon: ( ), }, { name: "Mobile", icon: ( ), }, { name: "CLI", icon: ( ), }, ]; const hosts = ["MacBook Pro", "Hetzner VM", "Dev server"]; const containerRef = React.useRef(null); const clientRefs = React.useRef<(HTMLDivElement | null)[]>([]); const hostRefs = React.useRef<(HTMLDivElement | null)[]>([]); const centerRef = React.useRef(null); const setClientRef = React.useCallback( (index: number) => (el: HTMLDivElement | null) => { clientRefs.current[index] = el; }, [], ); const setHostRef = React.useCallback( (index: number) => (el: HTMLDivElement | null) => { hostRefs.current[index] = el; }, [], ); const [paths, setPaths] = React.useState<{ left: string[]; right: string[] }>({ left: [], right: [], }); React.useEffect(() => { function computePaths() { const container = containerRef.current; const center = centerRef.current; if (!container || !center) return; const cRect = container.getBoundingClientRect(); const mRect = center.getBoundingClientRect(); const midL = mRect.left - cRect.left; const midR = mRect.right - cRect.left; const midY = mRect.top - cRect.top + mRect.height / 2; const left = clientRefs.current.map((el) => { if (!el) return ""; const r = el.getBoundingClientRect(); const x1 = r.right - cRect.left; const y1 = r.top - cRect.top + r.height / 2; const cpx = x1 + (midL - x1) * 0.6; return `M${x1},${y1} C${cpx},${y1} ${midL - (midL - x1) * 0.3},${midY} ${midL},${midY}`; }); const right = hostRefs.current.map((el) => { if (!el) return ""; const r = el.getBoundingClientRect(); const x2 = r.left - cRect.left; const y2 = r.top - cRect.top + r.height / 2; const cpx = midR + (x2 - midR) * 0.4; return `M${midR},${midY} C${cpx},${midY} ${x2 - (x2 - midR) * 0.3},${y2} ${x2},${y2}`; }); setPaths({ left, right }); } computePaths(); window.addEventListener("resize", computePaths); return () => window.removeEventListener("resize", computePaths); }, []); return ( <> {/* Mobile: vertical stack */}
{clients.map((c) => (
{c.icon} {c.name}
))}

E2E Encrypted Relay

or

Direct Connection

{hosts.map((h) => (
{h}
))}
{/* Desktop: horizontal with bezier curves */}
{/* SVG curves */} {[...paths.left, ...paths.right].map( (d) => d && ( ), )} {/* Clients */}
{clients.map((c, i) => (
{c.icon} {c.name}
))}
{/* Spacer */}
{/* Center label */}

E2E Encrypted Relay

or

Direct Connection

{/* Spacer */}
{/* Hosts */}
{hosts.map((h, i) => (
{h}
))}
); } function SelfHostedSection() { return ( ); } function ServiceProxySection() { const workspaces = [ { name: "fix-auth", url: "web.fix-auth.my-app.localhost" }, { name: "add-search", url: "web.add-search.my-app.localhost" }, { name: "upgrade-deps", url: "web.upgrade-deps.my-app.localhost" }, ]; return (
{/* Project */}
my-app
{/* Workspaces indented */}
{workspaces.map((ws) => (
{ws.name} npm run dev
{ws.url}
))}
); } function ShortcutsSection() { const shortcuts = [ { keys: ["⌘", "1-9"], action: "Switch panels" }, { keys: ["⌘", "D"], action: "Split vertical" }, { keys: ["⌘", "Shift", "D"], action: "Split horizontal" }, { keys: ["⌘", "W"], action: "Close panel" }, { keys: ["⌘", "N"], action: "New agent" }, { keys: ["⌘", "K"], action: "Command palette" }, ]; return (
{shortcuts.map((s) => (
{s.action}
{s.keys.map((k) => ( {k} ))}
))}
); } interface VoiceBarProps { index: number; barCount: number; } function VoiceBar({ index, barCount }: VoiceBarProps) { const style = React.useMemo(() => { const center = barCount / 2; const dist = Math.abs(index - center) / center; const envelope = 1 - dist * dist; const minH = 4; const maxH = 56; const baseH = minH + (maxH - minH) * envelope; const jitter = Math.sin(index * 2.3) * 0.3 + Math.cos(index * 1.7) * 0.2; const h = Math.max(minH, baseH * (0.5 + 0.5 * Math.abs(jitter + Math.sin(index * 0.8)))); return { height: h, animationName: "voice-bar", animationDuration: `${800 + (index % 5) * 200}ms`, animationTimingFunction: "ease-in-out", animationIterationCount: "infinite", animationDirection: "alternate" as const, animationDelay: `${(index % 7) * 80}ms`, }; }, [index, barCount]); return
; } const VOICE_BAR_COUNT = 48; const VOICE_BAR_INDICES = Array.from({ length: VOICE_BAR_COUNT }, (_, i) => i); function VoiceWaveform() { return (
{VOICE_BAR_INDICES.map((i) => ( ))}
); } const USER_WORDS = "Refactor the auth middleware to use the new session store, then run the test suite".split(" "); const RESPONSE_WORDS = "I'll update the auth middleware to use SessionStore instead of the legacy cookie-based approach. Let me refactor the middleware and update the tests.".split( " ", ); const DICTATION_LAG = 2; const RESPONSE_LAG = 3; const WORD_APPEAR_MS = 150; const RESPONSE_WORD_MS = 60; const PHASE_GAP_MS = 800; const LOOP_PAUSE_MS = 3000; type VoicePhase = | "dictation" | "dictation-flush" | "pause" | "response" | "response-flush" | "done"; function useVoiceConversation() { const [phase, setPhase] = React.useState("dictation"); const [wordIndex, setWordIndex] = React.useState(0); React.useEffect(() => { if (phase === "dictation") { if (wordIndex < USER_WORDS.length) { const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS); return () => clearTimeout(t); } setPhase("dictation-flush"); setWordIndex(0); return; } if (phase === "dictation-flush") { if (wordIndex < DICTATION_LAG) { const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS); return () => clearTimeout(t); } const t = setTimeout(() => { setPhase("pause"); }, PHASE_GAP_MS); return () => clearTimeout(t); } if (phase === "pause") { const t = setTimeout(() => { setPhase("response"); setWordIndex(0); }, PHASE_GAP_MS); return () => clearTimeout(t); } if (phase === "response") { if (wordIndex < RESPONSE_WORDS.length) { const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS); return () => clearTimeout(t); } setPhase("response-flush"); setWordIndex(0); return; } if (phase === "response-flush") { if (wordIndex < RESPONSE_LAG) { const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS); return () => clearTimeout(t); } const t = setTimeout(() => { setPhase("done"); }, LOOP_PAUSE_MS); return () => clearTimeout(t); } if (phase === "done") { const t = setTimeout(() => { setPhase("dictation"); setWordIndex(0); }, 0); return () => clearTimeout(t); } }, [phase, wordIndex]); // Compute effective word indices for rendering let dictationWordIndex: number; if (phase === "dictation") { dictationWordIndex = wordIndex; } else if (phase === "dictation-flush") { dictationWordIndex = USER_WORDS.length + wordIndex; } else { dictationWordIndex = USER_WORDS.length + DICTATION_LAG; } let responseWordIndex: number; if (phase === "response") { responseWordIndex = wordIndex; } else if (phase === "response-flush") { responseWordIndex = RESPONSE_WORDS.length + wordIndex; } else if (phase === "done") { responseWordIndex = RESPONSE_WORDS.length + RESPONSE_LAG; } else { responseWordIndex = 0; } const showResponse = phase === "response" || phase === "response-flush" || phase === "done"; return { dictationWordIndex, responseWordIndex, showResponse }; } function makeWordKey(words: string[], i: number): string { const word = words[i]; let occurrence = 0; for (let j = 0; j < i; j++) { if (words[j] === word) occurrence++; } return `${word}#${occurrence}`; } function WordSpan({ word, confirmed }: { word: string; confirmed: boolean }) { return ( {word}{" "} ); } function StreamingWords({ words, wordIndex, confirmLag = 2, }: { words: string[]; wordIndex: number; confirmLag?: number; }) { return (
{/* Invisible full text to reserve height at any viewport width */}

{words.join(" ")}

{/* Visible streaming text overlaid */}

{words.map((word, i) => { if (i >= wordIndex) return null; const confirmed = i < wordIndex - confirmLag; return ; })}

); } function LocalVoiceSection() { const { dictationWordIndex, responseWordIndex, showResponse } = useVoiceConversation(); return (
{/* Waveform area */}
{/* User dictation */}
{/* Agent response — always rendered to reserve space */}
); } function GetStarted() { return (
Supports
); } function DownloadButton() { const { version } = useRelease(); const detectedPlatform = useDetectedPlatform(); const primary = getDownloadOptions(version).find((o) => o.platform === detectedPlatform)!; const PrimaryIcon = primary.icon; return ( Download for {primary.label} ); } const SERVER_INSTALL_TRIGGER = ( ); const SERVER_INSTALL_FOOTNOTE = ( <> Requires Node.js 18+. Run paseo to start the daemon. ); function ServerInstallButton() { return ( ); } function ClaudeCodeIcon(props: React.SVGProps) { return ( ); } function CodexIcon(props: React.SVGProps) { return ( ); } function OpenCodeIcon(props: React.SVGProps) { return ( ); } function CopilotIcon(props: React.SVGProps) { return ( ); } function PiIcon(props: React.SVGProps) { return ( ); } const bashKeywords = new Set([ "while", "do", "done", "if", "then", "fi", "else", "break", "true", "false", ]); const bashCommands = new Set(["paseo", "echo", "jq"]); function tokenizeBashComment(code: string, i: number): { node: React.ReactNode; len: number } { const end = code.indexOf("\n", i); const comment = end === -1 ? code.slice(i) : code.slice(i, end); return { node: {comment}, len: comment.length, }; } function tokenizeBashDoubleQuoted(code: string, i: number): { node: React.ReactNode; len: number } { let j = i + 1; while (j < code.length && code[j] !== '"') { if (code[j] === "\\") j++; j++; } const str = code.slice(i, j + 1); return { node: {str}, len: str.length }; } function tokenizeBashSingleQuoted(code: string, i: number): { node: React.ReactNode; len: number } { let j = i + 1; while (j < code.length && code[j] !== "'") j++; const str = code.slice(i, j + 1); return { node: {str}, len: str.length }; } function tokenizeBashDollar(code: string, i: number): { node: React.ReactNode; len: number } { if (code[i + 1] === "(") { return { node: $(, len: 2 }; } let j = i + 1; while (j < code.length && /\w/.test(code[j])) j++; return { node: {code.slice(i, j)}, len: j - i, }; } function tokenizeBashFlag(code: string, i: number): { node: React.ReactNode; len: number } { let j = i; if (code[j + 1] === "-") j++; j++; while (j < code.length && /[\w-]/.test(code[j])) j++; return { node: {code.slice(i, j)}, len: j - i, }; } function tokenizeBashWord(code: string, i: number): { node: React.ReactNode; len: number } { let j = i; while (j < code.length && /\w/.test(code[j])) j++; const word = code.slice(i, j); const len = j - i; if (bashKeywords.has(word)) { return { node: {word}, len }; } if (bashCommands.has(word)) { return { node: {word}, len }; } return { node: word, len }; } function isBashFlagStart(code: string, i: number): boolean { return ( code[i] === "-" && (i === 0 || /\s/.test(code[i - 1])) && i + 1 < code.length && /[\w-]/.test(code[i + 1]) ); } function isBashCommentStart(code: string, i: number): boolean { return code[i] === "#" && (i === 0 || /[\s(]/.test(code[i - 1])); } function tokenizeBashChar(code: string, i: number): { node: React.ReactNode; len: number } { const c = code[i]; if (c === "|" || (c === "&" && code[i + 1] === "&")) { const op = c === "|" ? "|" : "&&"; return { node: {op}, len: op.length }; } if (c === "\\") return { node: \, len: 1 }; if (c === ")") return { node: ), len: 1 }; return { node: c, len: 1 }; } function nextBashToken(code: string, i: number): { node: React.ReactNode; len: number } { if (isBashCommentStart(code, i)) return tokenizeBashComment(code, i); if (code[i] === '"') return tokenizeBashDoubleQuoted(code, i); if (code[i] === "'") return tokenizeBashSingleQuoted(code, i); if (code[i] === "$") return tokenizeBashDollar(code, i); if (isBashFlagStart(code, i)) return tokenizeBashFlag(code, i); if (/[a-zA-Z_]/.test(code[i])) return tokenizeBashWord(code, i); return tokenizeBashChar(code, i); } function highlightBash(code: string): React.ReactNode { const tokens: React.ReactNode[] = []; let i = 0; let key = 0; while (i < code.length) { const { node, len } = nextBashToken(code, i); if (React.isValidElement(node)) { tokens.push(React.cloneElement(node, { key: key++ })); } else { tokens.push(node); } i += len; } return tokens; } function CLICodeBlock({ children }: { children: string }) { const [copied, setCopied] = React.useState(false); const handleCopy = React.useCallback(() => { navigator.clipboard.writeText(children); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [children]); return (
        {highlightBash(children)}
      
); } interface CLIExample { title: string; description: string; code: string; } const cliExamples: CLIExample[] = [ { title: "Run agents", description: "Launch agents locally or on any remote host. The --worktree flag spins up an isolated git branch so you can run multiple agents on the same repo without conflicts.", code: `paseo run "implement user authentication" paseo run --provider codex --worktree feature-x "implement feature X" paseo run --host devbox:6767 "run the full test suite" paseo ls # list running agents paseo attach abc123 # stream live output paseo send abc123 "also add tests" # follow-up task`, }, { title: "Loops", description: "Have one agent do the work, another verify the result, and loop until it passes. Built-in, no shell scripting needed.", code: `# Worker-verifier loop: fix tests until they pass paseo loop run "make all tests pass" \\ --verify "verify tests pass and the code is production-ready" \\ --verify-check "npm test" \\ --max-iterations 5 paseo loop ls # list running loops paseo loop logs abc123 # stream loop output`, }, { title: "Schedules", description: "Run agents on a cron schedule. Automate recurring tasks like dependency updates, security audits, or report generation.", code: `# Run a security audit every Monday at 9am paseo schedule create --cron "0 9 * * 1" \\ "audit the codebase for security issues and open PRs for fixes" paseo schedule ls # list all schedules paseo schedule pause abc123 # pause a schedule paseo schedule delete abc123 # remove a schedule`, }, ]; function PhoneShowcase() { const containerRef = React.useRef(null); const textInView = useInView(containerRef, { once: true, margin: "-80px" }); // Scroll-linked animation: track how far through the container the user has scrolled const { scrollYProgress } = useScroll({ target: containerRef, offset: ["start end", "center center"], }); // Responsive slide distance const [slideDistance, setSlideDistance] = React.useState(260); React.useEffect(() => { function update() { setSlideDistance(window.innerWidth < 768 ? 140 : 260); } update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, []); // Side phones start at x=0 (behind center) and slide out to final position const sideOpacity = useTransform(scrollYProgress, [0.2, 0.6], [0, 1]); const leftX = useTransform(scrollYProgress, [0.2, 0.6], [0, -slideDistance]); const rightX = useTransform(scrollYProgress, [0.2, 0.6], [0, slideDistance]); const leftPhoneStyle = React.useMemo( () => ({ opacity: sideOpacity, x: leftX, rotateY: -15, scale: 0.97 }), [sideOpacity, leftX], ); const rightPhoneStyle = React.useMemo( () => ({ opacity: sideOpacity, x: rightX, rotateY: 15, scale: 0.97 }), [sideOpacity, rightX], ); const centerPhoneAnimate = React.useMemo(() => (textInView ? FADE_IN : {}), [textInView]); const textAnimate = React.useMemo(() => (textInView ? FADE_IN : {}), [textInView]); return (
{/* Arrow + text */}

When you want to step away from your desk,
you can.

The native mobile app has full feature parity with desktop.

{/* Phone trio — side phones are absolute, start behind center, slide outward with perspective rotation */}
{/* Left phone — rotated to face inward */} Paseo sessions list {/* Center phone */} Paseo agent chat {/* Right phone — rotated to face inward */} Paseo diff view
); } function CLITabButton({ title, index, active, onSelect, }: { title: string; index: number; active: boolean; onSelect: (i: number) => void; }) { const handleClick = React.useCallback(() => onSelect(index), [onSelect, index]); return ( ); } function CLISection() { const [activeIndex, setActiveIndex] = React.useState(0); const active = cliExamples[activeIndex]; return (
{cliExamples.map((example, i) => ( ))}
{active.code}
Full CLI reference
); } function FAQ() { return (

FAQ

Yes. Paseo is free and open source. You need Claude Code, Codex, or OpenCode installed with your own credentials. Voice is local-first by default and can optionally use OpenAI speech providers if you configure them. Paseo doesn't send your code anywhere. Agents run locally and talk to their own APIs as they normally would. For remote access, you can use the optional{" "} end-to-end encrypted relay , connect directly over your local network, or use your own tunnel. Claude Code, Codex, and OpenCode. Each agent runs as its own process using its own CLI. Paseo doesn't modify or wrap their behavior. No. You can run the daemon headless with{" "} npm install -g @getpaseo/cli && paseo {" "} and use the CLI, web app, or mobile app to connect. The desktop app just bundles the daemon with a UI. Voice runs locally on your device by default. You talk, the app transcribes and sends it to your agent as text. Optionally, you can configure OpenAI speech providers for higher-quality transcription and text-to-speech. See the{" "} voice docs . Yes. You can use the hosted relay (end-to-end encrypted, Paseo can't read your traffic), set up your own tunnel (Tailscale, Cloudflare Tunnel, etc.), or expose the daemon port directly. See{" "} configuration . No. Paseo works in any directory. Worktrees are optional and only relevant if you use git. You can run agents anywhere you'd normally work.

We can't make promises on behalf of providers.

That said, Paseo launches the official first-party CLIs (Claude Code, Codex, OpenCode) as subprocesses. It doesn't extract tokens or call inference APIs directly. From the provider's perspective, usage through Paseo is indistinguishable from running the CLI yourself.

I've been using Paseo with all providers for months without issue.

When you launch an agent with the worktree option (from the app, desktop, or CLI), Paseo creates a git worktree and runs the agent inside it. The agent works on an isolated branch without touching your main working directory. See the{" "} worktrees docs .
); } function SponsorCTA() { return (

I built Paseo because I wanted better tools for coding agents on my own setup. It's an independent open source project, built around freedom of choice and real workflows. If you like what I'm building, consider becoming a supporter.

- Mo

); } function FAQItem({ question, children }: { question: string; children: React.ReactNode }) { return (
+ − {question}
{children}
); }