# 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 Agent Terminal Demo for React: a coding agent's terminal beside the app it edits. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/agent-session
- Tags: AI, Code, Developer
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/agent-session.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/agent-session
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/agent-session.json
```

## Usage

The main demo shows typical usage.

### 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/agent-session-demo
```

```tsx title="components/agent-session-demo.tsx"
"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>
  );
}
```

## 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`.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/agent-session-locale-demo
```

```tsx title="components/agent-session-locale-demo.tsx"
"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 &amp; 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>
  );
}
```

### 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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/agent-session-cli-demo
```

```tsx title="components/agent-session-cli-demo.tsx"
"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>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `script` (required) | `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 (`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` |  | 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` |  | Called once when a run finishes, including after Skip. Use it to move on to the next script. |
| `onEffect` | `(effect: string) => void` |  | Called with an effect's name each time its change lands in the preview during playback. |
| `className` | `string` |  | 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` |  | `AgentSessionTarget`: the name a script line gives in its `effect`. The target changes when that line lands. |
| `target.fallback` | `React.ReactNode` |  | `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)` |  | `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` |  | `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. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/agent-session.tsx`, keep these, which make it feel and behave right:

- 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 (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="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}>&gt;</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}>&gt;</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>
  );
}
```
