Agent Session
AI Agent Terminal Demo for React
A coding agent at work in its terminal, beside the app it's changing. The prompt types itself, each command runs with a live timer and lands its output in one burst, and every line that writes a file shows up in the app a beat later, ringed. Point at a change on either side and its other half lights up.
- AI
- Code
- Developer
Uses motion, clsx, tailwind-merge
<AgentSession />replays the demo when you let go.
web — agent
Visitors
12,480+8.2%
Signups
1,204+3.1%
No funnels yet
Ask for one, or add it from Funnels.
- You: Add a signup funnel to the dashboard and track each step
- Ran Bash: npx tern events list --match signup. signup_viewed page /signup. signup_submitted form #signup-form. email_verified server POST /api/verify
- Ran Bash: npx tern add funnel signup --to app/dashboard. + app/dashboard/signup-funnel.tsx SignupFunnel. ~ app/dashboard/layout.tsx Funnels (1)
- Ran Bash: npx tern track signup_viewed signup_submitted email_verified. + app/signup/page.tsx track("signup_viewed"). + app/signup/form.tsx track("signup_submitted"). + app/api/verify/route.ts track("email_verified")
- Agent: Added a three-step signup funnel to the dashboard. Last week 1,204 of 12,480 visitors finished, and most of the drop happens before the form is sent.
"use client";
import { motion } from "motion/react";
import {
AgentSession,
AgentSessionTarget,
useAgentSessionEffect,
type AgentSessionStep,
} from "@/components/ui/agent-session";
const script: AgentSessionStep[] = [
{ prompt: "Add a signup funnel to the dashboard and track each step" },
{
run: "npx tern events list --match signup",
ms: 900,
output: [
"signup_viewed page /signup",
"signup_submitted form #signup-form",
"email_verified server POST /api/verify",
],
more: 4,
},
{
run: "npx tern add funnel signup --to app/dashboard",
ms: 1100,
output: [
{ text: "+ app/dashboard/signup-funnel.tsx SignupFunnel", tone: "added", effect: "funnel" },
{ text: "~ app/dashboard/layout.tsx Funnels (1)", effect: "nav" },
],
},
{
run: "npx tern track signup_viewed signup_submitted email_verified",
ms: 1200,
pace: 420,
output: [
{ text: '+ app/signup/page.tsx track("signup_viewed")', tone: "added", effect: "step-1" },
{ text: '+ app/signup/form.tsx track("signup_submitted")', tone: "added", effect: "step-2" },
{ text: '+ app/api/verify/route.ts track("email_verified")', tone: "added", effect: "step-3" },
],
},
{
say: "Added a three-step signup funnel to the dashboard. Last week 1,204 of 12,480 visitors finished, and most of the drop happens before the form is sent.",
},
];
const steps = [
{ effect: "step-1", label: "Viewed signup", count: "12,480", share: 1 },
{ effect: "step-2", label: "Sent the form", count: "2,911", share: 0.233 },
{ effect: "step-3", label: "Verified email", count: "1,204", share: 0.096 },
];
const nav = ["Overview", "Funnels", "Events", "Retention", "Settings"];
export function AgentSessionDemo() {
return (
<AgentSession
script={script}
title="web — agent"
url="localhost:3000/dashboard"
className="max-w-5xl [--agent-session-height:26rem]"
>
<div className="@container/app flex h-full text-zinc-900 dark:text-zinc-100">
<aside className="hidden w-32 shrink-0 flex-col gap-0.5 border-r border-zinc-950/[0.06] bg-zinc-50/70 p-2.5 @[24rem]/app:flex dark:border-white/[0.06] dark:bg-white/[0.02]">
<div className="mb-3 flex items-center gap-1.5 px-1.5 pt-1 text-[13px] font-semibold tracking-tight">
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path d="M2 13 8 3l6 10H2Z" fill="currentColor" />
</svg>
Tern
</div>
{nav.map((item, i) => (
<div
key={item}
className={
i === 0
? "flex items-center justify-between rounded-md bg-white px-2 py-1.5 text-xs font-medium shadow-sm ring-1 ring-zinc-950/5 dark:bg-white/10 dark:ring-white/5"
: "flex items-center justify-between rounded-md px-2 py-1.5 text-xs text-zinc-500 dark:text-zinc-400"
}
>
{item}
{item === "Funnels" && (
<AgentSessionTarget effect="nav" className="[--agent-session-ring-radius:999px]">
<span className="rounded-full bg-zinc-900 px-1.5 text-[10px] leading-4 font-medium text-white tabular-nums dark:bg-white dark:text-zinc-900">
1
</span>
</AgentSessionTarget>
)}
</div>
))}
</aside>
<main className="flex min-w-0 flex-1 flex-col gap-3 p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-semibold tracking-tight">Overview</h3>
<span className="text-[11px] text-zinc-400">Last 7 days</span>
</div>
<div className="grid grid-cols-2 gap-2">
<Stat label="Visitors" value="12,480" delta="+8.2%" />
<Stat label="Signups" value="1,204" delta="+3.1%" />
</div>
<AgentSessionTarget
effect="funnel"
className="[--agent-session-ring-radius:14px]"
fallback={
<div className="flex h-full min-h-36 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-zinc-950/15 text-center dark:border-white/15">
<p className="text-xs font-medium">No funnels yet</p>
<p className="text-[11px] text-zinc-400">Ask for one, or add it from Funnels.</p>
</div>
}
>
<div className="rounded-xl bg-white p-3 ring-1 ring-zinc-950/[0.08] shadow-sm dark:bg-white/[0.04] dark:ring-white/10">
<div className="mb-2.5 flex items-baseline justify-between">
<p className="text-xs font-semibold">Signup funnel</p>
<p className="text-[11px] text-zinc-400 tabular-nums">9.6% convert</p>
</div>
<div className="flex flex-col gap-2">
{steps.map((step) => (
<FunnelRow key={step.effect} {...step} />
))}
</div>
</div>
</AgentSessionTarget>
</main>
</div>
</AgentSession>
);
}
function Stat({ label, value, delta }: { label: string; value: string; delta: string }) {
return (
<div className="rounded-xl bg-white p-2.5 ring-1 ring-zinc-950/[0.08] dark:bg-white/[0.04] dark:ring-white/10">
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">{label}</p>
<p className="mt-0.5 flex items-baseline gap-1.5">
<span className="text-base font-semibold tracking-tight tabular-nums">{value}</span>
<span className="text-[10px] font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
</p>
</div>
);
}
// Each row fills once its tracking call lands, so the bars arrive in the order the agent wrote them.
function FunnelRow({ effect, label, count, share }: (typeof steps)[number]) {
const landed = useAgentSessionEffect(effect);
return (
<AgentSessionTarget effect={effect} enter="none" className="[--agent-session-ring-radius:8px]">
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between text-[11px]">
<span className="text-zinc-600 dark:text-zinc-300">{label}</span>
<motion.span
initial={false}
animate={{ opacity: landed ? 1 : 0.35 }}
className="font-medium tabular-nums"
>
{landed ? count : "—"}
</motion.span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-zinc-100 dark:bg-white/[0.07]">
<motion.div
initial={false}
animate={{ transform: `scaleX(${landed ? share : 0})` }}
transition={{ type: "spring", visualDuration: 0.6, bounce: 0 }}
className="h-full origin-left rounded-full bg-zinc-900 dark:bg-zinc-100"
/>
</div>
</div>
</AgentSessionTarget>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Agent Session: A coding agent at work in its terminal, beside the app it's changing. The prompt types itself, each command runs with a live timer and lands its output in one burst, and every line that writes a file shows up in the app a beat later, ringed. Point at a change on either side and its other half lights up. This demo, Analytics Setup: An agent adds a signup funnel to a product analytics dashboard. The card arrives with its file, then each bar fills as the tracking call for that step is written. 1. Install it: npx shadcn@latest add @keyframeui/agent-session-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/agent-session-demo.json instead.) It adds the demo, components/agent-session-demo.tsx, and the component, components/ui/agent-session.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <AgentSession> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): Playback rate for typing, commands and output. `2` plays twice as fast. - theme (default "dark"): `"dark"` keeps a dark terminal in both color schemes. `"auto"` is light in light mode and dark in dark mode. - enter on <AgentSessionTarget> (default "pop"): How the content arrives: a springy pop out of a slight blur, a plain fade, or no motion of its own. The ring flashes either way. - duration on <AgentSessionTarget> (default 0.42): How long the pop takes to land, in seconds. Only for `enter="pop"`. - bounce on <AgentSessionTarget> (default 0.22): How much the pop overshoots before it settles, from `0` (none) up. Only for `enter="pop"`. 4. Don't change, in components/ui/agent-session.tsx: - The session is one plan built ahead by `buildPlan` and played on a ms clock, one `setTimeout` to the next event at a time; the screen is a function of `t`, so the clock stops and resumes cleanly. - A jump (Skip, Replay, a new script or `speed`) sets `jumped`, so everything lands in place without animating; typing jitter comes from `noise`, so every replay types the same. - Cause, reload, effect: a line with an `effect` lands, the address bar sweeps, and its target changes `hmr` ms later. - Lines, prompts and targets animate `transform` and `opacity` only (plus the pop's blur), and both target looks set every property, so switching to reduced motion leaves no blur behind. - Reduced motion still plays the session a step at a time, text whole and changes fading; the animated panes are `aria-hidden` and `<Transcript>` gives screen readers the script as text. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/agent-session.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/agent-session.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/agent-sessionInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/agent-session.tsx"use client"; import * as React from "react"; import { animate, motion, useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - The session is one plan built ahead by `buildPlan` and played on a ms clock, one `setTimeout` to * the next event at a time; the screen is a function of `t`, so the clock stops and resumes cleanly. * - A jump (Skip, Replay, a new script or `speed`) sets `jumped`, so everything lands in place * without animating; typing jitter comes from `noise`, so every replay types the same. * - Cause, reload, effect: a line with an `effect` lands, the address bar sweeps, and its target * changes `hmr` ms later. * - Lines, prompts and targets animate `transform` and `opacity` only (plus the pop's blur), and * both target looks set every property, so switching to reduced motion leaves no blur behind. * - Reduced motion still plays the session a step at a time, text whole and changes fading; the * animated panes are `aria-hidden` and `<Transcript>` gives screen readers the script as text. */ const TIMING = { /** Playback rate, as a multiple of the default pace (the `speed` prop). */ speed: 1, // The script's pace at the default speed; `speed` divides these. /** Between coming into view and the first keystroke, in ms. */ boot: 500, /** Per typed character, in ms. */ key: 34, /** Variation per keystroke, in ms; deterministic. */ jitter: 26, /** Extra after a comma or full stop, in ms. */ comma: 90, /** A finished prompt (or shell command) waiting before it's sent, in ms. */ send: 380, /** Between a prompt landing and the agent's first move, in ms. */ think: 560, /** How long a command runs when its step gives no `ms`, in ms. */ run: 900, /** Between output lines landing, in ms. */ line: 26, /** Cap on one burst's whole stagger, in ms. */ spread: 320, /** Per word of a reply, in ms. */ word: 22, /** Between steps, in ms. */ gap: 420, /** Between a line writing a file and the app showing it, in ms. The reload sweep starts there. */ hmr: 140, /** Between steps when reduced motion plays it step by step, in ms. */ stepped: 650, // Not divided by `speed`. /** Between clock readings, so spinners and timers move between keystrokes, in ms. */ tick: 100, /** Per frame of the spinner, in ms of the session clock. */ spinner: 80, /** A finished session waiting before `loop` restarts it, in ms. */ hold: 4000, /** A line or prompt landing in the transcript, in seconds. */ rise: { duration: 0.2, ease: [0.22, 1, 0.36, 1] }, /** Reduced motion: lines and changes appear, in seconds. */ fade: { duration: 0.2, ease: "easeOut" }, /** A change landing in the app (the target's `duration` and `bounce` props). */ pop: { type: "spring", visualDuration: 0.42, bounce: 0.22 }, /** The placeholder making way, in seconds. */ leave: { duration: 0.14, ease: "easeOut" }, /** The landing ring and line glow fading out, in seconds. */ flash: { duration: 0.9, ease: [0.22, 1, 0.36, 1] }, /** The address bar's reload sweep, in seconds. */ sweep: { duration: 0.42, ease: [0.22, 1, 0.36, 1] }, /** The sweep's fade, in seconds, held until 60% of the way. */ sweepFade: { duration: 0.6, times: [0, 0.6, 1] }, /** Hover highlight on and off, in seconds. */ link: { duration: 0.15, ease: "easeOut" }, /** The transcript following new lines, or scrolling to a pointed-at line. */ scroll: { type: "spring", visualDuration: 0.3, bounce: 0 }, /** One blink of a running command's dot, in seconds. */ blink: { duration: 0.9, ease: "easeInOut" }, /** The dot going solid when its command finishes, in seconds. */ dot: { duration: 0.15 }, /** One blink of the caret, in seconds: on for the first half, off for the second. */ caret: { duration: 1.05, times: [0, 0.5, 0.5, 1], ease: "linear" }, } as const; /* ───────────────────────────────────────────────────────── * AGENT SESSION STORYBOARD * * rest a terminal beside the app it is about to change: * an empty transcript over the input box, and the * app in its before state * view a third of it in view, then 0.5s: the prompt types * itself into the input box, 34ms a key with a * little human jitter and a pause after commas * send 0.38s after the last key the input clears and the * prompt rises 8px into the transcript * run a command line appears with a blinking dot and a * live timer under it; when it finishes the dot * turns green (red if it failed) and the output * lands in one burst, 26ms a line, capped at 0.32s * land a line that writes a file glows in the accent, the * preview's address bar runs a reload sweep, and * 140ms later the change pops into the app (a 0.42s * spring with a little give) inside a ring that * fades over 0.9s. Cause, reload, effect * reply the agent's answer streams in a word at a time * done the status line reads "Worked for 9s · 3 commands" * and the title bar offers Replay * link point at a change in the app and the line that made * it lights up (scrolled into view if needed); point * at that line and the change is ringed * again Replay, a new `script`, or `loop` start from the * top with the app back in its before state * sleep off screen or in a hidden tab the clock stops and * resumes where it was * keys Skip lands everything at once, Replay starts over; * screen readers get the whole transcript as text * reduced the session still plays, a step at a time: each * prompt, command and reply appears whole, a beat * apart, and changes fade in without a pop or rise * ───────────────────────────────────────────────────────── */ const OFFSET = { lift: 8, // px a sent prompt rises drop: 6, // px an output line rises } as const; const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; /* ── Script ─────────────────────────────────────────────── */ export type AgentSessionTone = "default" | "muted" | "added" | "removed" | "success" | "error"; /** One line of output: plain text, or text with a tone and the change it makes in the preview. */ export type AgentSessionLine = string | { text: string; tone?: AgentSessionTone; effect?: string }; export type AgentSessionStep = /** You, typing to the agent. Types into the input box, then moves into the transcript. */ | { prompt: string } /** The agent running a tool. `tool` defaults to "Bash", `ms` is how long it runs, `pace` spaces its output lines. */ | { run: string; tool?: string; output?: AgentSessionLine[]; more?: number; ms?: number; pace?: number; failed?: boolean; } /** The agent's reply, streamed a word at a time. `effect` lands when it finishes. */ | { say: string; effect?: string } /** A command you type at a `$` prompt yourself, for plain CLI sessions. */ | { shell: string; output?: AgentSessionLine[]; more?: number; ms?: number; pace?: number; failed?: boolean }; type PlanLine = { text: string; tone: AgentSessionTone; effect?: string; at: number }; type PlanStep = | { kind: "prompt"; text: string; keys: number[]; sent: number } | { kind: "run"; shell: boolean; tool: string; command: string; keys: number[]; called: number; done: number; lines: PlanLine[]; more?: { count: number; at: number }; failed: boolean; } | { kind: "say"; parts: string[]; at: number[]; start: number }; interface Plan { steps: PlanStep[]; /** Every moment the picture changes, sorted. */ events: number[]; /** When each change lands in the preview. */ effects: Map<string, number>; total: number; /** The first prompt's send, where the "Worked for" clock starts. */ begun: number; commands: number; agent: boolean; } // Same jitter on the server, the client and every replay. const noise = (i: number) => { const x = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); }; function lineOf(line: AgentSessionLine): Omit<PlanLine, "at"> { return typeof line === "string" ? { text: line, tone: "default" } : { tone: "default", ...line }; } function buildPlan(script: AgentSessionStep[], speed: number, stepped: boolean): Plan { const k = 1 / Math.max(0.1, speed); const steps: PlanStep[] = []; const marks: number[] = [0]; const effects = new Map<string, number>(); let begun = -1; let commands = 0; let t = TIMING.boot * k; const land = (effect: string | undefined, at: number) => { if (!effect) return; const time = at + TIMING.hmr * k; if (!effects.has(effect) || effects.get(effect)! > time) effects.set(effect, time); marks.push(time); }; const type = (text: string) => { const keys: number[] = []; for (let i = 0; i < text.length; i++) { if (!stepped) { const ch = text[i - 1]; t += (TIMING.key + (noise(i) - 0.5) * TIMING.jitter + (ch === "," || ch === "." ? TIMING.comma : 0)) * k; } keys.push(t); } marks.push(...keys); return keys; }; for (const step of script) { if ("prompt" in step) { const keys = type(step.prompt); const sent = t + (stepped ? TIMING.stepped / 2 : TIMING.send) * k; if (begun < 0) begun = sent; steps.push({ kind: "prompt", text: step.prompt, keys, sent }); marks.push(sent); t = sent + (stepped ? TIMING.stepped : TIMING.think) * k; } else if ("run" in step || "shell" in step) { const shell = "shell" in step; const command = shell ? step.shell : step.run; const keys = shell ? type(command) : []; const called = t + (shell ? (stepped ? TIMING.stepped / 2 : TIMING.send) : 0) * k; const done = called + (stepped ? Math.min(step.ms ?? TIMING.run, TIMING.stepped) : (step.ms ?? TIMING.run)) * k; const raw = (step.output ?? []).map(lineOf); // One quick burst by default; `pace` writes them one at a time, like files landing. const gap = stepped ? 0 : (step.pace ?? Math.min(TIMING.line, TIMING.spread / Math.max(1, raw.length))) * k; const lines = raw.map((line, i) => ({ ...line, at: done + i * gap })); lines.forEach((line) => land(line.effect, line.at)); const more = step.more ? { count: step.more, at: done + raw.length * gap } : undefined; commands++; steps.push({ kind: "run", shell, tool: shell ? "" : ("tool" in step && step.tool) || "Bash", command, keys, called, done, lines, more, failed: !!step.failed, }); marks.push(called, done, ...lines.map((line) => line.at), more?.at ?? done); t = (more?.at ?? lines.at(-1)?.at ?? done) + (stepped ? TIMING.stepped : TIMING.gap) * k; } else { const parts = step.say.match(/\S+\s*/g) ?? [step.say]; const start = t; const at = parts.map((_, i) => (stepped ? start : start + i * TIMING.word * k)); steps.push({ kind: "say", parts, at, start }); marks.push(...at); land(step.effect, at.at(-1) ?? start); t = (at.at(-1) ?? start) + (stepped ? TIMING.stepped : TIMING.gap) * k; } } const total = Math.max(0, ...marks) + 1; // Clock readings, so spinners and timers move between keystrokes. for (let tick = 0; tick < total; tick += TIMING.tick) marks.push(tick); marks.push(total); const events = [...new Set(marks.map((m) => Math.round(m)))].sort((a, b) => a - b); return { steps, events, effects, total: events.at(-1) ?? 0, begun: Math.max(0, begun), commands, agent: script.some((step) => "prompt" in step || "say" in step || "run" in step), }; } function nextEvent(events: number[], t: number) { let lo = 0; let hi = events.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (events[mid] <= t) lo = mid + 1; else hi = mid; } return events[lo] ?? Infinity; } /* ── Store ──────────────────────────────────────────────── */ type State = { /** Session clock, in ms. */ t: number; /** The clock got here by a jump (Skip, Replay, a new script), so nothing animates on the way. */ jumped: boolean; /** The change being pointed at, in either pane. */ hover: string | null; /** Bumped on every restart, so a run completes once. */ run: number; }; function createStore() { let state: State = { t: 0, jumped: true, hover: null, run: 0 }; const listeners = new Set<() => void>(); const emit = () => listeners.forEach((listener) => listener()); return { get: () => state, seek(t: number, jumped: boolean) { if (state.t === t && state.jumped === jumped) return; state = { ...state, t, jumped }; emit(); }, restart() { state = { ...state, t: 0, jumped: true, run: state.run + 1 }; emit(); }, point(hover: string | null) { if (state.hover === hover) return; state = { ...state, hover }; emit(); }, subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, }; } type Store = ReturnType<typeof createStore>; const SessionContext = React.createContext<{ store: Store; effects: Map<string, number>; reduced: boolean; } | null>(null); function useSession(part: string) { const session = React.useContext(SessionContext); if (!session) throw new Error(`${part} must be used inside <AgentSession>.`); return session; } function useLanded(effect: string) { const { store, effects } = useSession("useAgentSessionEffect"); const at = effects.get(effect) ?? Infinity; return React.useSyncExternalStore( store.subscribe, () => store.get().t >= at, () => false, ); } /** * True once the line with this `effect` has landed in the current run. For previews that change * in their own way, like a bar that fills or a number that counts up. */ export function useAgentSessionEffect(effect: string) { return useLanded(effect); } const subscribeNever = () => () => {}; function usePageVisible() { return React.useSyncExternalStore( (onChange) => { document.addEventListener("visibilitychange", onChange); return () => document.removeEventListener("visibilitychange", onChange); }, () => document.visibilityState === "visible", () => true, ); } /* ── Look ───────────────────────────────────────────────── */ const THEMES = { dark: { window: "bg-zinc-950 text-zinc-300 ring-white/10 shadow-[0_24px_48px_-28px_rgb(0_0_0/0.55)]", bar: "border-white/[0.07]", title: "text-zinc-400", strong: "text-zinc-50", muted: "text-zinc-500", band: "bg-white/[0.06]", input: "border-white/10 bg-white/[0.03]", button: "text-zinc-400 hover:bg-white/[0.08] hover:text-zinc-100 focus-visible:ring-white/30", lights: ["bg-[#ff5f57]", "bg-[#febc2e]", "bg-[#28c840]"], tones: { default: "text-zinc-300", muted: "text-zinc-500", added: "text-emerald-300", removed: "text-rose-300", success: "text-emerald-400", error: "text-rose-400", }, dot: { idle: "bg-zinc-500", ok: "bg-emerald-400", failed: "bg-rose-400", say: "bg-zinc-100" }, }, auto: { window: "bg-white text-zinc-700 ring-zinc-950/10 shadow-[0_24px_48px_-32px_rgb(0_0_0/0.3)] dark:bg-zinc-950 dark:text-zinc-300 dark:ring-white/10", bar: "border-zinc-950/[0.07] dark:border-white/[0.07]", title: "text-zinc-500 dark:text-zinc-400", strong: "text-zinc-950 dark:text-zinc-50", muted: "text-zinc-400 dark:text-zinc-500", band: "bg-zinc-950/[0.04] dark:bg-white/[0.06]", input: "border-zinc-950/10 bg-zinc-950/[0.02] dark:border-white/10 dark:bg-white/[0.03]", button: "text-zinc-500 hover:bg-zinc-950/[0.06] hover:text-zinc-900 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100 dark:focus-visible:ring-white/30", lights: ["bg-zinc-200 dark:bg-zinc-700", "bg-zinc-200 dark:bg-zinc-700", "bg-zinc-200 dark:bg-zinc-700"], tones: { default: "text-zinc-700 dark:text-zinc-300", muted: "text-zinc-400 dark:text-zinc-500", added: "text-emerald-700 dark:text-emerald-300", removed: "text-rose-600 dark:text-rose-300", success: "text-emerald-600 dark:text-emerald-400", error: "text-rose-600 dark:text-rose-400", }, dot: { idle: "bg-zinc-400 dark:bg-zinc-500", ok: "bg-emerald-500 dark:bg-emerald-400", failed: "bg-rose-500 dark:bg-rose-400", say: "bg-zinc-900 dark:bg-zinc-100", }, }, } as const; type Theme = (typeof THEMES)[keyof typeof THEMES]; const ACCENT = "var(--agent-session-accent)"; /* ── AgentSession ───────────────────────────────────────── */ export interface AgentSessionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** What happens, in order: prompts you type, commands the agent runs with their output, its replies, and shell commands. */ script: AgentSessionStep[]; /** The app being changed, shown in a browser window beside the terminal. Wrap the parts that change in `AgentSessionTarget`. Leave it out for a terminal on its own. */ children?: React.ReactNode; /** Text in the terminal's title bar. */ title?: string; /** Address shown in the preview window's bar. */ url?: string; /** Start by itself once a third of it is in view. Off, it waits for the Play button. */ autoPlay?: boolean; /** Stop the clock where it is. Off screen and in hidden tabs it stops by itself. */ paused?: boolean; /** Play again from the top, after a 4s pause, forever or this many more times. */ loop?: boolean | number; /** Playback rate. 2 plays twice as fast. */ speed?: number; /** "dark" is a dark terminal in both color schemes; "auto" is light in light mode and dark in dark mode. */ theme?: "dark" | "auto"; /** Placeholder in the input box before anything is typed. */ placeholder?: string; /** Called when a run finishes. */ onComplete?: () => void; /** Called with the effect's name each time a change lands in the preview. */ onEffect?: (effect: string) => void; } /** * A coding agent in its terminal beside the app it's changing. Lines that write files land in the * preview as they print, and pointing at either half lights up the other. */ export function AgentSession({ script, children, title = "agent", url = "localhost:3000", autoPlay = true, paused = false, loop = false, speed = TIMING.speed, theme = "dark", placeholder = "Ask the agent to change something", onComplete, onEffect, className, style, ...props }: AgentSessionProps) { // useReducedMotion is already true in the hydration render, so reduced markup waits for hydration. const hydrated = React.useSyncExternalStore(subscribeNever, () => true, () => false); const reduced = !!useReducedMotion() && hydrated; const plan = React.useMemo(() => buildPlan(script, speed, reduced), [script, speed, reduced]); const [store] = React.useState(createStore); const state = React.useSyncExternalStore(store.subscribe, store.get, store.get); const rootRef = React.useRef<HTMLDivElement>(null); const seen = useInView(rootRef, { once: true, amount: 0.33 }); const inView = useInView(rootRef, { amount: 0.05 }); const visible = usePageVisible(); const [pressedPlay, setPressedPlay] = React.useState(false); const started = autoPlay ? seen || pressedPlay : pressedPlay; const running = started && inView && visible && !paused; // Callbacks change every render; the clock shouldn't restart for that. const handlers = React.useRef({ onComplete, onEffect }); React.useEffect(() => { handlers.current = { onComplete, onEffect }; }); // A new script (or speed) starts over with the app in its before state. const firstPlan = React.useRef(true); React.useEffect(() => { if (firstPlan.current) { firstPlan.current = false; return; } store.restart(); }, [plan, store]); const loops = React.useRef(0); const completed = React.useRef(-1); React.useEffect(() => { if (!running) return; let timer = 0; const advance = () => { const { t, run } = store.get(); if (t >= plan.total) { if (completed.current !== run) { completed.current = run; handlers.current.onComplete?.(); } const more = loop === true || (typeof loop === "number" && loops.current < loop); if (more) { // The restart bumps `run`, which starts this effect over from the top. timer = window.setTimeout(() => { loops.current++; store.restart(); }, TIMING.hold); } return; } const next = nextEvent(plan.events, t); timer = window.setTimeout(() => { store.seek(next, false); for (const [effect, at] of plan.effects) { if (at > t && at <= next) handlers.current.onEffect?.(effect); } advance(); }, next - t); }; advance(); return () => window.clearTimeout(timer); }, [running, plan, store, loop, state.run]); const done = state.t >= plan.total; const skip = () => store.seek(plan.total, true); const replay = () => { loops.current = 0; store.restart(); setPressedPlay(true); }; const look = THEMES[theme]; const context = React.useMemo(() => ({ store, effects: plan.effects, reduced }), [store, plan.effects, reduced]); const control = !started ? "play" : done ? "replay" : "skip"; return ( <SessionContext.Provider value={context}> <div ref={rootRef} className={cn( "@container/agent-session w-full [--agent-session-accent:#8b5cf6] dark:[--agent-session-accent:#a78bfa]", className, )} style={style} {...props} > <div className={cn( "grid gap-3", children != null && "@[44rem]/agent-session:grid-cols-[minmax(0,1.08fr)_minmax(0,1fr)]", )} > <Terminal plan={plan} state={state} look={look} title={title} placeholder={placeholder} control={control} onControl={() => (control === "skip" ? skip() : control === "replay" ? replay() : setPressedPlay(true))} store={store} reduced={reduced} /> {children != null && ( <Preview url={url} plan={plan} t={state.t} jumped={state.jumped} reduced={reduced}> {children} </Preview> )} </div> <Transcript script={script} done={done} /> </div> </SessionContext.Provider> ); } /* ── Terminal ───────────────────────────────────────────── */ // The animated transcript is hidden from screen readers; <Transcript> reads the same script as text. function Terminal({ plan, state, look, title, placeholder, control, onControl, store, reduced, }: { plan: Plan; state: State; look: Theme; title: string; placeholder: string; control: "play" | "skip" | "replay"; onControl: () => void; store: Store; reduced: boolean; }) { const { t, jumped, hover } = state; const instant = jumped; const scrollRef = React.useRef<HTMLDivElement>(null); const stick = React.useRef(true); const auto = React.useRef<ReturnType<typeof animate> | null>(null); // Follow new lines to the bottom unless the reader scrolled up to look at something. React.useLayoutEffect(() => { const el = scrollRef.current; if (!el || !stick.current) return; const target = el.scrollHeight - el.clientHeight; if (Math.abs(el.scrollTop - target) < 1) return; auto.current?.stop(); if (instant || reduced) { el.scrollTop = target; return; } auto.current = animate(el.scrollTop, target, { ...TIMING.scroll, onUpdate: (v) => (el.scrollTop = v), onComplete: () => (auto.current = null), }); }, [t, instant, reduced]); React.useEffect(() => { const el = scrollRef.current; if (!el) return; const onIntent = () => auto.current?.stop(); const onScroll = () => { if (auto.current) return; stick.current = el.scrollHeight - el.clientHeight - el.scrollTop < 24; }; el.addEventListener("wheel", onIntent, { passive: true }); el.addEventListener("touchstart", onIntent, { passive: true }); el.addEventListener("scroll", onScroll, { passive: true }); return () => { el.removeEventListener("wheel", onIntent); el.removeEventListener("touchstart", onIntent); el.removeEventListener("scroll", onScroll); }; }, []); // Pointing at a change in the app brings the line that made it into view. React.useEffect(() => { const el = scrollRef.current; if (!el || !hover) return; const line = el.querySelector<HTMLElement>(`[data-effect="${CSS.escape(hover)}"]`); if (!line) return; const box = el.getBoundingClientRect(); const rect = line.getBoundingClientRect(); if (rect.top >= box.top && rect.bottom <= box.bottom) return; const top = rect.top - box.top + el.scrollTop; auto.current?.stop(); stick.current = false; const target = Math.max(0, Math.min(top - el.clientHeight / 2, el.scrollHeight - el.clientHeight)); if (reduced) el.scrollTop = target; else auto.current = animate(el.scrollTop, target, { ...TIMING.scroll, onUpdate: (v) => (el.scrollTop = v), onComplete: () => (auto.current = null), }); }, [hover, reduced]); const spinner = SPINNER[Math.floor(t / TIMING.spinner) % SPINNER.length]; const typing = plan.steps.find( (step): step is Extract<PlanStep, { kind: "prompt" }> => step.kind === "prompt" && step.keys.length > 0 && t >= step.keys[0] && t < step.sent, ); const active = plan.steps.find( (step): step is Extract<PlanStep, { kind: "run" }> => step.kind === "run" && t >= step.called && t < step.done, ); const done = t >= plan.total; const begun = t >= plan.begun && plan.steps.some((step) => step.kind === "prompt"); const enter = (rise: number) => instant ? false : reduced ? { opacity: 0 } : { opacity: 0, transform: `translateY(${rise}px)` }; return ( <div className={cn( "flex h-[var(--agent-session-height,24rem)] min-w-0 flex-col overflow-hidden rounded-xl ring-1", look.window, )} > <div className={cn("relative flex h-9 shrink-0 items-center border-b px-3", look.bar)}> <div className="flex gap-1.5" aria-hidden> {look.lights.map((light, i) => ( <span key={i} className={cn("size-2.5 rounded-full", light)} /> ))} </div> <p className={cn("pointer-events-none absolute inset-x-24 truncate text-center text-xs", look.title)}> {title} </p> <button type="button" onClick={onControl} className={cn( "ml-auto flex h-6 items-center gap-1 rounded-md px-1.5 text-[11px] font-medium outline-none transition-colors focus-visible:ring-2", look.button, )} > <ControlIcon control={control} /> {control === "play" ? "Play" : control === "skip" ? "Skip" : "Replay"} </button> </div> <div ref={scrollRef} aria-hidden className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3 font-mono text-[12px] leading-[1.65] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > <div className="flex flex-col gap-3"> {plan.steps.map((step, i) => { if (step.kind === "prompt") { if (t < step.sent) return null; return ( <motion.div key={i} initial={enter(OFFSET.lift)} animate={{ opacity: 1, transform: "translateY(0px)" }} transition={reduced ? TIMING.fade : TIMING.rise} className={cn("-mx-2 flex gap-2 rounded-md px-2 py-1", look.band, look.strong)} > <span className={look.muted}>></span> <span className="min-w-0 whitespace-pre-wrap break-words">{step.text}</span> </motion.div> ); } if (step.kind === "run") { const startAt = step.shell ? (step.keys[0] ?? step.called) : step.called; if (t < startAt) return null; const typed = step.shell ? step.command.slice(0, step.keys.filter((key) => key <= t).length) : step.command; const isTyping = step.shell && t < step.called; const isRunning = t >= step.called && t < step.done; const status = t < step.done ? "idle" : step.failed ? "failed" : "ok"; const lines = step.lines.filter((line) => line.at <= t); return ( <div key={i} className="min-w-0"> <motion.div initial={step.shell ? false : enter(OFFSET.drop)} animate={{ opacity: 1, transform: "translateY(0px)" }} transition={reduced ? TIMING.fade : TIMING.rise} className="flex min-w-0 items-baseline gap-2" > {step.shell ? ( <span className={look.muted}>$</span> ) : ( <StatusDot className={look.dot[status]} blink={isRunning && !reduced} /> )} <span className="min-w-0 truncate whitespace-pre"> {step.shell ? ( <span className={look.strong}>{typed}</span> ) : ( <> <span className={cn("font-semibold", look.strong)}>{step.tool}</span> <span className={look.muted}>(</span> {step.command} <span className={look.muted}>)</span> </> )} {isTyping && <Caret solid />} </span> </motion.div> {isRunning && ( <div className={cn("flex gap-2 pl-[1.1rem]", look.muted)}> <span>{reduced ? "…" : spinner}</span> <span className="tabular-nums"> Running {((t - step.called) / 1000).toFixed(1)}s </span> </div> )} {(lines.length > 0 || (step.more && t >= step.more.at)) && ( <div className="grid grid-cols-[1.1rem_minmax(0,1fr)]"> {lines.map((line, j) => ( <OutputLine key={j} line={line} first={j === 0} look={look} enter={enter(OFFSET.drop)} instant={instant} reduced={reduced} linked={!!line.effect && hover === line.effect} onPoint={(on) => line.effect && store.point(on ? line.effect : null)} /> ))} {step.more && t >= step.more.at && ( <motion.div initial={enter(OFFSET.drop)} animate={{ opacity: 1, transform: "translateY(0px)" }} transition={reduced ? TIMING.fade : TIMING.rise} className={cn("col-start-2", look.muted)} > … +{step.more.count} lines </motion.div> )} </div> )} </div> ); } const shown = step.at.filter((at) => at <= t).length; if (shown === 0) return null; return ( <div key={i} className={cn("flex min-w-0 items-baseline gap-2", look.strong)}> <StatusDot className={look.dot.say} /> <p className="min-w-0 whitespace-pre-wrap break-words font-sans text-[13px] leading-relaxed"> {step.parts.slice(0, shown).join("")} </p> </div> ); })} </div> </div> {plan.agent && ( <div className={cn("shrink-0 border-t px-3 pt-2.5 pb-2", look.bar)} aria-hidden> <div className={cn( "flex h-9 min-w-0 items-center gap-2 rounded-lg border px-3 font-mono text-[12px]", look.input, )} > <span className={look.muted}>></span> {typing ? ( <span className={cn("min-w-0 truncate whitespace-pre", look.strong)}> {typing.text.slice(0, typing.keys.filter((key) => key <= t).length)} <Caret solid /> </span> ) : ( <span className={cn("min-w-0 truncate", look.muted)}> {!begun && <Caret solid={reduced} />} {!begun && <span className="ml-1">{placeholder}</span>} </span> )} </div> <p className={cn("mt-1.5 flex h-4 min-w-0 items-center gap-1.5 px-1 text-[11px] tabular-nums", look.muted)}> {done && begun ? ( <> <Check /> <span className="truncate"> Worked for {Math.max(1, Math.round((plan.total - plan.begun) / 1000))}s · {plan.commands}{" "} {plan.commands === 1 ? "command" : "commands"} </span> </> ) : begun ? ( <> <span className="font-mono">{reduced ? "…" : spinner}</span> <span className="truncate"> {active ? `Running ${active.tool}` : "Thinking"} · {Math.floor((t - plan.begun) / 1000)}s </span> </> ) : null} </p> </div> )} </div> ); } function OutputLine({ line, first, look, enter, instant, reduced, linked, onPoint, }: { line: PlanLine; first: boolean; look: Theme; enter: false | { opacity: number; transform?: string }; instant: boolean; reduced: boolean; linked: boolean; onPoint: (on: boolean) => void; }) { return ( <> <span className={cn("select-none", look.muted)}>{first ? "⎿" : ""}</span> <motion.div data-effect={line.effect} initial={enter} animate={{ opacity: 1, transform: "translateY(0px)" }} transition={reduced ? TIMING.fade : TIMING.rise} onPointerEnter={(e) => e.pointerType === "mouse" && onPoint(true)} onPointerLeave={(e) => e.pointerType === "mouse" && onPoint(false)} className={cn("relative -mx-1.5 min-w-0 rounded px-1.5", look.tones[line.tone])} > {line.effect && ( <> {/* The glow it lands with, then the highlight while its change is pointed at. */} <motion.span aria-hidden initial={instant ? false : { opacity: 0.7 }} animate={{ opacity: 0 }} transition={TIMING.flash} className="pointer-events-none absolute inset-0 rounded" style={{ background: `color-mix(in oklab, ${ACCENT} 32%, transparent)` }} /> <motion.span aria-hidden initial={false} animate={{ opacity: linked ? 1 : 0 }} transition={TIMING.link} className="pointer-events-none absolute inset-0 rounded" style={{ background: `color-mix(in oklab, ${ACCENT} 20%, transparent)`, boxShadow: `inset 2px 0 0 ${ACCENT}`, }} /> </> )} <span className="relative block truncate whitespace-pre">{line.text}</span> </motion.div> </> ); } function StatusDot({ className, blink = false }: { className: string; blink?: boolean }) { return ( <motion.span aria-hidden animate={blink ? { opacity: [1, 0.25, 1] } : { opacity: 1 }} transition={blink ? { ...TIMING.blink, repeat: Infinity } : TIMING.dot} className={cn("inline-block size-[7px] shrink-0 translate-y-[-1px] rounded-full transition-colors", className)} /> ); } function Caret({ solid = false }: { solid?: boolean }) { return ( <motion.span aria-hidden animate={solid ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }} transition={ solid ? { duration: 0 } : { duration: TIMING.caret.duration, repeat: Infinity, times: [...TIMING.caret.times], ease: TIMING.caret.ease, } } className="ml-px inline-block h-[1.15em] w-[0.5em] translate-y-[0.2em] bg-current opacity-80" /> ); } function ControlIcon({ control }: { control: "play" | "skip" | "replay" }) { return ( <svg viewBox="0 0 16 16" className="size-3" fill="none" stroke="currentColor" strokeWidth={1.6} aria-hidden> {control === "play" ? ( <path d="M5 3.5v9l7-4.5-7-4.5Z" fill="currentColor" stroke="none" /> ) : control === "skip" ? ( <path d="M3.5 3.5 8 8l-4.5 4.5M8.5 3.5 13 8l-4.5 4.5" strokeLinecap="round" strokeLinejoin="round" /> ) : ( <path d="M3 8a5 5 0 1 0 1.6-3.7M3 2.5v2.8h2.8" strokeLinecap="round" strokeLinejoin="round" /> )} </svg> ); } function Check() { return ( <svg viewBox="0 0 16 16" className="size-3 shrink-0" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden> <path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" /> </svg> ); } /* ── Preview ────────────────────────────────────────────── */ // A browser window around the app. Each landing runs a reload sweep across its address bar. function Preview({ url, plan, t, jumped, reduced, children, }: { url: string; plan: Plan; t: number; jumped: boolean; reduced: boolean; children: React.ReactNode; }) { // The latest file write, which kicks off a reload `hmr` ms before its change shows. let reload = -1; for (const at of plan.effects.values()) { const start = at - TIMING.hmr; if (start <= t && start > reload) reload = start; } return ( <div className="flex h-[var(--agent-session-preview-height,var(--agent-session-height,24rem))] min-w-0 flex-col overflow-hidden rounded-xl bg-white ring-1 @[44rem]/agent-session:h-[var(--agent-session-height,24rem)] ring-zinc-950/10 shadow-[0_24px_48px_-32px_rgb(0_0_0/0.3)] dark:bg-zinc-900 dark:ring-white/10"> <div className="relative flex h-9 shrink-0 items-center justify-center border-b border-zinc-950/[0.06] px-3 dark:border-white/[0.07]"> <div className="absolute left-3 flex gap-1.5" aria-hidden> <span className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" /> <span className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" /> <span className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" /> </div> <span className="max-w-[60%] truncate rounded-md bg-zinc-100 px-2.5 py-0.5 font-mono text-[11px] text-zinc-500 dark:bg-white/[0.06] dark:text-zinc-400"> {url} </span> {reload >= 0 && !jumped && !reduced && ( <motion.span key={reload} aria-hidden initial={{ transform: "scaleX(0)", opacity: 1 }} animate={{ transform: "scaleX(1)", opacity: [1, 1, 0] }} transition={{ ...TIMING.sweep, opacity: { duration: TIMING.sweepFade.duration, times: [...TIMING.sweepFade.times] }, }} className="absolute inset-x-0 -bottom-px h-0.5 origin-left" style={{ background: ACCENT }} /> )} </div> <div className="relative min-h-0 flex-1 overflow-hidden">{children}</div> </div> ); } /* ── AgentSessionTarget ─────────────────────────────────── */ export interface AgentSessionTargetProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** The name a script line gives in its `effect`. The target changes when that line lands. */ effect: string; /** Shown until the change lands, like a placeholder icon or the old label. Without it the target waits invisible, keeping its space. */ fallback?: React.ReactNode; /** The changed content. A function gets `landed` for previews that change in their own way. */ children: React.ReactNode | ((landed: boolean) => React.ReactNode); /** How the content arrives: a springy pop, a plain fade, or no motion of its own (the ring still flashes). */ enter?: "pop" | "fade" | "none"; /** How long the pop takes to land, in seconds. Only for `enter="pop"`. */ duration?: number; /** How much the pop overshoots and settles, from 0 (none) up. Only for `enter="pop"`. */ bounce?: number; } /** * A part of the preview that a script line changes. Swaps `fallback` for its children when the line * lands, with a ring around it, and links to that line on hover. */ export function AgentSessionTarget({ effect, fallback, children, enter = "pop", duration = TIMING.pop.visualDuration, bounce = TIMING.pop.bounce, className, onPointerEnter, onPointerLeave, ...props }: AgentSessionTargetProps) { const { store, reduced } = useSession("<AgentSessionTarget>"); const landed = useLanded(effect); const linked = React.useSyncExternalStore( store.subscribe, () => store.get().hover === effect, () => false, ); // A landing by Skip, Replay or a new script doesn't animate. const instant = React.useSyncExternalStore( store.subscribe, () => store.get().jumped, () => true, ); const motionOf = enter === "none" ? null : enter === "fade" || reduced ? "fade" : "pop"; // Both looks set every property, so switching to reduced motion after hydration leaves no blur behind. const hidden = motionOf === "pop" ? { opacity: 0, transform: "scale(0.9)", filter: "blur(4px)" } : { opacity: 0, transform: "scale(1)", filter: "blur(0px)" }; const shown = { opacity: 1, transform: "scale(1)", filter: "blur(0px)" }; return ( <div className={cn("relative grid [&>*]:[grid-area:1/1]", className)} onPointerEnter={(e) => { if (e.pointerType === "mouse" && landed) store.point(effect); onPointerEnter?.(e); }} onPointerLeave={(e) => { if (e.pointerType === "mouse") store.point(null); onPointerLeave?.(e); }} {...props} > {fallback != null && ( <motion.div aria-hidden={landed} initial={false} animate={landed ? { opacity: 0, transform: "scale(0.96)" } : { opacity: 1, transform: "scale(1)" }} transition={instant ? { duration: 0 } : TIMING.leave} className={cn("min-w-0", landed && "pointer-events-none")} > {fallback} </motion.div> )} {motionOf == null ? ( <div className="min-w-0">{typeof children === "function" ? children(landed) : children}</div> ) : ( <motion.div aria-hidden={!landed} initial={false} animate={landed ? shown : hidden} transition={ instant ? { duration: 0 } : motionOf === "pop" ? { ...TIMING.pop, visualDuration: duration, bounce } : TIMING.fade } className={cn("min-w-0", !landed && "pointer-events-none")} > {typeof children === "function" ? children(landed) : children} </motion.div> )} {landed && ( <motion.span aria-hidden initial={instant ? false : { opacity: 1, transform: "scale(0.97)" }} animate={{ opacity: 0, transform: "scale(1.04)" }} transition={TIMING.flash} className="pointer-events-none absolute -inset-1 rounded-[var(--agent-session-ring-radius,10px)]" style={{ boxShadow: `0 0 0 2px ${ACCENT}` }} /> )} <motion.span aria-hidden initial={false} animate={{ opacity: linked && landed ? 1 : 0 }} transition={TIMING.link} className="pointer-events-none absolute -inset-1 rounded-[var(--agent-session-ring-radius,10px)]" style={{ boxShadow: `0 0 0 2px ${ACCENT}`, background: `color-mix(in oklab, ${ACCENT} 8%, transparent)`, }} /> </div> ); } /* ── Transcript ─────────────────────────────────────────── */ // The whole session as text for screen readers, with a polite note when a run finishes. function Transcript({ script, done }: { script: AgentSessionStep[]; done: boolean }) { const text = (line: AgentSessionLine) => (typeof line === "string" ? line : line.text); const reply = [...script].reverse().find((step): step is { say: string } => "say" in step); return ( <div className="sr-only"> <ol> {script.map((step, i) => ( <li key={i}> {"prompt" in step ? `You: ${step.prompt}` : "say" in step ? `Agent: ${step.say}` : "run" in step ? `Ran ${step.tool ?? "Bash"}: ${step.run}. ${(step.output ?? []).map(text).join(". ")}` : `$ ${step.shell}. ${(step.output ?? []).map(text).join(". ")}`} </li> ))} </ol> <p aria-live="polite">{done ? `Session finished.${reply ? ` ${reply.say}` : ""}` : ""}</p> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Agent Session. Each one installs whole, with the component included.
Localized Checkout
A checkout translated into German one label at a time, each English label as the `fallback` that its German line replaces. A teal accent through `--agent-session-accent`.
field-and-fold — agent
Field & Fold
1 item
- You: Translate the checkout into German, with prices in euros
- Ran Bash: npx glossa extract app/checkout. 14 strings in 3 files. 2 prices · 1 date format
- Ran Bash: npx glossa translate de --glossary brand.json. + messages/de.json 14 strings · reviewed against glossary
- Ran Update: app/checkout/page.tsx. ~ "Contact" → "Kontakt". ~ "Email" → "E-Mail". ~ "Shipping" → "Versand". ~ "3–5 days" → "3–5 Werktage". ~ "$128.00" → "118,00 €". ~ "Pay now" → "Jetzt bezahlen"
- Agent: The checkout reads in German now, with euro prices from your price list. Delivery says Werktage, business days, which is how German shops write it.
"use client";
import { AgentSession, AgentSessionTarget, type AgentSessionStep } from "@/components/ui/agent-session";
const script: AgentSessionStep[] = [
{ prompt: "Translate the checkout into German, with prices in euros" },
{
run: "npx glossa extract app/checkout",
ms: 800,
output: ["14 strings in 3 files", "2 prices · 1 date format"],
},
{
run: "npx glossa translate de --glossary brand.json",
ms: 1300,
output: [{ text: "+ messages/de.json 14 strings · reviewed against glossary", tone: "added" }],
},
{
run: "app/checkout/page.tsx",
tool: "Update",
ms: 600,
pace: 300,
output: [
{ text: '~ "Contact" → "Kontakt"', effect: "contact" },
{ text: '~ "Email" → "E-Mail"', effect: "email" },
{ text: '~ "Shipping" → "Versand"', effect: "shipping" },
{ text: '~ "3–5 days" → "3–5 Werktage"', effect: "days" },
{ text: '~ "$128.00" → "118,00 €"', effect: "total" },
{ text: '~ "Pay now" → "Jetzt bezahlen"', effect: "pay" },
],
},
{
say: "The checkout reads in German now, with euro prices from your price list. Delivery says Werktage, business days, which is how German shops write it.",
},
];
/** The English label until its line lands, then the German one. */
function Swap({ effect, from, to, className }: { effect: string; from: string; to: string; className?: string }) {
return (
<AgentSessionTarget effect={effect} fallback={<span className={className}>{from}</span>} className="justify-items-start">
<span className={className}>{to}</span>
</AgentSessionTarget>
);
}
export function AgentSessionLocaleDemo() {
return (
<AgentSession
script={script}
title="field-and-fold — agent"
url="localhost:3000/de/checkout"
className="max-w-5xl [--agent-session-accent:#0d9488] dark:[--agent-session-accent:#2dd4bf]"
>
<div className="flex h-full items-start justify-center overflow-hidden bg-stone-50 p-4 text-stone-900 dark:bg-stone-950/40 dark:text-stone-100">
<div className="w-full max-w-xs">
<div className="mb-3 flex items-center justify-between">
<p className="font-serif text-base tracking-tight">Field & Fold</p>
<p className="text-[11px] text-stone-500">1 item</p>
</div>
<div className="flex flex-col gap-3 rounded-2xl bg-white p-3.5 ring-1 ring-stone-950/[0.07] shadow-sm dark:bg-white/[0.04] dark:ring-white/10">
<section className="flex flex-col gap-1.5">
<Swap effect="contact" from="Contact" to="Kontakt" className="text-xs font-semibold" />
<div className="flex h-8 items-center rounded-lg px-2.5 ring-1 ring-stone-950/10 dark:ring-white/10">
<Swap effect="email" from="Email" to="E-Mail" className="text-[11px] text-stone-400" />
<span className="ml-auto text-[11px] text-stone-600 dark:text-stone-300">lena@posteo.de</span>
</div>
</section>
<section className="flex flex-col gap-1.5">
<Swap effect="shipping" from="Shipping" to="Versand" className="text-xs font-semibold" />
<div className="flex items-center justify-between rounded-lg bg-stone-50 px-2.5 py-2 ring-1 ring-stone-950/10 dark:bg-white/[0.03] dark:ring-white/10">
<div className="flex flex-col">
<span className="text-[11px] font-medium">Standard</span>
<Swap effect="days" from="3–5 days" to="3–5 Werktage" className="text-[11px] text-stone-500" />
</div>
<span className="text-[11px] text-stone-500">0,00 €</span>
</div>
</section>
<div className="flex items-baseline justify-between border-t border-stone-950/[0.06] pt-3 dark:border-white/10">
<span className="text-xs text-stone-500">Wool overshirt, oat</span>
<Swap effect="total" from="$128.00" to="118,00 €" className="text-sm font-semibold tabular-nums" />
</div>
<AgentSessionTarget
effect="pay"
className="[--agent-session-ring-radius:999px]"
fallback={<PayButton label="Pay now" />}
>
<PayButton label="Jetzt bezahlen" />
</AgentSessionTarget>
</div>
</div>
</div>
</AgentSession>
);
}
function PayButton({ label }: { label: string }) {
return (
<span className="flex h-9 w-full items-center justify-center rounded-full bg-stone-900 text-xs font-medium text-white dark:bg-stone-100 dark:text-stone-900">
{label}
</span>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Agent Session: A coding agent at work in its terminal, beside the app it's changing. The prompt types itself, each command runs with a live timer and lands its output in one burst, and every line that writes a file shows up in the app a beat later, ringed. Point at a change on either side and its other half lights up. This demo, Localized Checkout: A checkout translated into German one label at a time, each English label as the `fallback` that its German line replaces. A teal accent through `--agent-session-accent`. 1. Install it: npx shadcn@latest add @keyframeui/agent-session-locale-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/agent-session-locale-demo.json instead.) It adds the demo, components/agent-session-locale-demo.tsx, and the component, components/ui/agent-session.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <AgentSession> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): Playback rate for typing, commands and output. `2` plays twice as fast. - theme (default "dark"): `"dark"` keeps a dark terminal in both color schemes. `"auto"` is light in light mode and dark in dark mode. - enter on <AgentSessionTarget> (default "pop"): How the content arrives: a springy pop out of a slight blur, a plain fade, or no motion of its own. The ring flashes either way. - duration on <AgentSessionTarget> (default 0.42): How long the pop takes to land, in seconds. Only for `enter="pop"`. - bounce on <AgentSessionTarget> (default 0.22): How much the pop overshoots before it settles, from `0` (none) up. Only for `enter="pop"`. 4. Don't change, in components/ui/agent-session.tsx: - The session is one plan built ahead by `buildPlan` and played on a ms clock, one `setTimeout` to the next event at a time; the screen is a function of `t`, so the clock stops and resumes cleanly. - A jump (Skip, Replay, a new script or `speed`) sets `jumped`, so everything lands in place without animating; typing jitter comes from `noise`, so every replay types the same. - Cause, reload, effect: a line with an `effect` lands, the address bar sweeps, and its target changes `hmr` ms later. - Lines, prompts and targets animate `transform` and `opacity` only (plus the pop's blur), and both target looks set every property, so switching to reduced motion leaves no blur behind. - Reduced motion still plays the session a step at a time, text whole and changes fading; the animated panes are `aria-hidden` and `<Transcript>` gives screen readers the script as text. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/agent-session.md
CLI Feature Cards
Two small terminals with `theme="auto"` and no preview, as feature cards: a hidden key prompt, and a typo that gets a suggestion instead of a write.
~/web
- $ npx tern login. ? Project key ••••••••••••••••••••. ✓ Signed in as maya@fieldandfold.co. key saved to ~/.config/tern/auth.json
You paste it once at a hidden prompt. The agent only ever runs tern track, so the key never reaches its context or your repo.
~/web
- $ npx tern track signup_veiwed. error no event "signup_veiwed". did you mean signup_viewed?. nothing was written
- $ npx tern track signup_viewed. + app/signup/page.tsx track("signup_viewed"). ✓ 1 event tracked
Event names must match. A near miss suggests the real one, and nothing touches your code until the name is right.
"use client";
import { AgentSession, type AgentSessionStep } from "@/components/ui/agent-session";
const cards: { title: string; body: string; script: AgentSessionStep[] }[] = [
{
title: "Your key stays in your terminal",
body: "You paste it once at a hidden prompt. The agent only ever runs tern track, so the key never reaches its context or your repo.",
script: [
{
shell: "npx tern login",
ms: 1400,
output: [
{ text: "? Project key ••••••••••••••••••••", tone: "muted" },
{ text: "✓ Signed in as maya@fieldandfold.co", tone: "success" },
{ text: " key saved to ~/.config/tern/auth.json", tone: "muted" },
],
pace: 360,
},
],
},
{
title: "A typo gets a question, not a write",
body: "Event names must match. A near miss suggests the real one, and nothing touches your code until the name is right.",
script: [
{
shell: "npx tern track signup_veiwed",
ms: 700,
failed: true,
output: [
{ text: 'error no event "signup_veiwed"', tone: "error" },
{ text: " did you mean signup_viewed?", tone: "default" },
{ text: " nothing was written", tone: "muted" },
],
},
{
shell: "npx tern track signup_viewed",
ms: 900,
output: [
{ text: '+ app/signup/page.tsx track("signup_viewed")', tone: "added" },
{ text: "✓ 1 event tracked", tone: "success" },
],
},
],
},
];
export function AgentSessionCliDemo() {
return (
<div className="@container w-full max-w-4xl">
<div className="grid gap-x-6 gap-y-10 @[38rem]:grid-cols-2">
{cards.map((card) => (
<div key={card.title} className="flex min-w-0 flex-col gap-4">
<AgentSession
script={card.script}
theme="auto"
title="~/web"
className="[--agent-session-height:13.5rem]"
/>
<div className="px-1">
<h3 className="text-sm font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">{card.title}</h3>
<p className="mt-1 text-sm leading-relaxed text-pretty text-zinc-500 dark:text-zinc-400">{card.body}</p>
</div>
</div>
))}
</div>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Agent Session: A coding agent at work in its terminal, beside the app it's changing. The prompt types itself, each command runs with a live timer and lands its output in one burst, and every line that writes a file shows up in the app a beat later, ringed. Point at a change on either side and its other half lights up. This demo, CLI Feature Cards: Two small terminals with `theme="auto"` and no preview, as feature cards: a hidden key prompt, and a typo that gets a suggestion instead of a write. 1. Install it: npx shadcn@latest add @keyframeui/agent-session-cli-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/agent-session-cli-demo.json instead.) It adds the demo, components/agent-session-cli-demo.tsx, and the component, components/ui/agent-session.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <AgentSession> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): Playback rate for typing, commands and output. `2` plays twice as fast. - theme (default "dark"): `"dark"` keeps a dark terminal in both color schemes. `"auto"` is light in light mode and dark in dark mode. - enter on <AgentSessionTarget> (default "pop"): How the content arrives: a springy pop out of a slight blur, a plain fade, or no motion of its own. The ring flashes either way. - duration on <AgentSessionTarget> (default 0.42): How long the pop takes to land, in seconds. Only for `enter="pop"`. - bounce on <AgentSessionTarget> (default 0.22): How much the pop overshoots before it settles, from `0` (none) up. Only for `enter="pop"`. 4. Don't change, in components/ui/agent-session.tsx: - The session is one plan built ahead by `buildPlan` and played on a ms clock, one `setTimeout` to the next event at a time; the screen is a function of `t`, so the clock stops and resumes cleanly. - A jump (Skip, Replay, a new script or `speed`) sets `jumped`, so everything lands in place without animating; typing jitter comes from `noise`, so every replay types the same. - Cause, reload, effect: a line with an `effect` lands, the address bar sweeps, and its target changes `hmr` ms later. - Lines, prompts and targets animate `transform` and `opacity` only (plus the pop's blur), and both target looks set every property, so switching to reduced motion leaves no blur behind. - Reduced motion still plays the session a step at a time, text whole and changes fading; the animated panes are `aria-hidden` and `<Transcript>` gives screen readers the script as text. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/agent-session.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
script Required | AgentSessionStep[] | None | What happens, in order. { prompt } types into the input box and moves into the transcript. { run, tool?, output?, more?, ms?, pace?, failed? } is the agent running a tool (tool defaults to "Bash"): it runs for ms, then its output lands in a burst, or a line every pace ms. { say, effect? } streams the reply a word at a time. { shell } is a command you type at a $ prompt, for plain CLI sessions. An output line is a string or { text, tone?, effect? }, where tone is "muted", "added", "removed", "success" or "error" and effect names the change it makes in the preview. A new script starts from the top. |
children | React.ReactNode | None | The app being changed, shown in a browser window beside the terminal (under it in narrow containers). Wrap the parts that change in AgentSessionTarget. Leave it out for a terminal on its own. |
title | string | "agent" | Text in the terminal's title bar, like a folder and the agent's name. |
url | string | "localhost:3000" | Address in the preview window's bar. |
autoPlay | boolean | true | Start by itself once a third of it is in view. Turned off, it waits for the Play button in the title bar. |
paused | boolean | false | Stop the clock where it is. Off screen and in hidden tabs it stops by itself and picks up where it left off. |
loop | boolean | number | false | Play again from the top after a 4s pause: forever with true, or this many more times. |
speed | number | 1 | Playback rate for typing, commands and output. 2 plays twice as fast. |
theme | "dark" | "auto" | "dark" | "dark" keeps a dark terminal in both color schemes. "auto" is light in light mode and dark in dark mode. |
placeholder | string | "Ask the agent to change something" | Shown in the input box before the first prompt is typed. Scripts with only shell steps have no input box. |
onComplete | () => void | None | Called once when a run finishes, including after Skip. Use it to move on to the next script. |
onEffect | (effect: string) => void | None | Called with an effect's name each time its change lands in the preview during playback. |
className | string | None | Extra classes for the root. Set --agent-session-accent for the glow, sweep and rings, --agent-session-height for the height of each window (24rem by default), and --agent-session-preview-height for the preview alone when it stacks under the terminal. |
target.effect Required | string | None | AgentSessionTarget: the name a script line gives in its effect. The target changes when that line lands. |
target.fallback | React.ReactNode | None | AgentSessionTarget: shown until the change lands, like a placeholder icon, an empty state or the old label. Without it the target waits invisible, keeping its space. |
target.children Required | React.ReactNode | ((landed: boolean) => React.ReactNode) | None | AgentSessionTarget: the changed content. A function gets landed, for parts that change in their own way, like a bar that fills. useAgentSessionEffect(effect) returns the same flag anywhere inside the session. |
target.enter | "pop" | "fade" | "none" | "pop" | AgentSessionTarget: how the content arrives: a springy pop out of a slight blur, a plain fade, or no motion of its own. The ring flashes either way. |
target.duration | number | 0.42 | AgentSessionTarget: how long the pop takes to land, in seconds. Only for enter="pop". |
target.bounce | number | 0.22 | AgentSessionTarget: how much the pop overshoots before it settles, from 0 (none) up. Only for enter="pop". |
target.className | string | None | AgentSessionTarget: classes for the wrapper, a grid that stacks the fallback and the content. Set --agent-session-ring-radius to match the corners of what it wraps. |
scriptRequiredType
AgentSessionStep[]What happens, in order.
{ prompt }types into the input box and moves into the transcript.{ run, tool?, output?, more?, ms?, pace?, failed? }is the agent running a tool (tooldefaults to"Bash"): it runs forms, then itsoutputlands in a burst, or a line everypacems.{ say, effect? }streams the reply a word at a time.{ shell }is a command you type at a$prompt, for plain CLI sessions. An output line is a string or{ text, tone?, effect? }, wheretoneis"muted","added","removed","success"or"error"andeffectnames the change it makes in the preview. A new script starts from the top.childrenType
React.ReactNodeThe app being changed, shown in a browser window beside the terminal (under it in narrow containers). Wrap the parts that change in
AgentSessionTarget. Leave it out for a terminal on its own.titleType
stringDefault"agent"Text in the terminal's title bar, like a folder and the agent's name.
urlType
stringDefault"localhost:3000"Address in the preview window's bar.
autoPlayType
booleanDefaulttrueStart by itself once a third of it is in view. Turned off, it waits for the Play button in the title bar.
pausedType
booleanDefaultfalseStop the clock where it is. Off screen and in hidden tabs it stops by itself and picks up where it left off.
loopType
boolean | numberDefaultfalsePlay again from the top after a 4s pause: forever with
true, or this many more times.speedType
numberDefault1Playback rate for typing, commands and output.
2plays twice as fast.themeType
"dark" | "auto"Default"dark""dark"keeps a dark terminal in both color schemes."auto"is light in light mode and dark in dark mode.placeholderType
stringDefault"Ask the agent to change something"Shown in the input box before the first prompt is typed. Scripts with only
shellsteps have no input box.onCompleteType
() => voidCalled once when a run finishes, including after Skip. Use it to move on to the next script.
onEffectType
(effect: string) => voidCalled with an effect's name each time its change lands in the preview during playback.
classNameType
stringExtra classes for the root. Set
--agent-session-accentfor the glow, sweep and rings,--agent-session-heightfor the height of each window (24rem by default), and--agent-session-preview-heightfor the preview alone when it stacks under the terminal.target.effectRequiredType
stringAgentSessionTarget: the name a script line gives in itseffect. The target changes when that line lands.target.fallbackType
React.ReactNodeAgentSessionTarget: shown until the change lands, like a placeholder icon, an empty state or the old label. Without it the target waits invisible, keeping its space.target.childrenRequiredType
React.ReactNode | ((landed: boolean) => React.ReactNode)AgentSessionTarget: the changed content. A function getslanded, for parts that change in their own way, like a bar that fills.useAgentSessionEffect(effect)returns the same flag anywhere inside the session.target.enterType
"pop" | "fade" | "none"Default"pop"AgentSessionTarget: how the content arrives: a springy pop out of a slight blur, a plain fade, or no motion of its own. The ring flashes either way.target.durationType
numberDefault0.42AgentSessionTarget: how long the pop takes to land, in seconds. Only forenter="pop".target.bounceType
numberDefault0.22AgentSessionTarget: how much the pop overshoots before it settles, from0(none) up. Only forenter="pop".target.classNameType
stringAgentSessionTarget: classes for the wrapper, a grid that stacks the fallback and the content. Set--agent-session-ring-radiusto match the corners of what it wraps.