{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "agent-session",
  "type": "registry:ui",
  "title": "Agent Session",
  "description": "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.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "ai",
    "code",
    "developer"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/agent-session",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/agent-session.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - The session is one plan built ahead by `buildPlan` and played on a ms clock, one `setTimeout` to\n *   the next event at a time; the screen is a function of `t`, so the clock stops and resumes cleanly.\n * - A jump (Skip, Replay, a new script or `speed`) sets `jumped`, so everything lands in place\n *   without animating; typing jitter comes from `noise`, so every replay types the same.\n * - Cause, reload, effect: a line with an `effect` lands, the address bar sweeps, and its target\n *   changes `hmr` ms later.\n * - Lines, prompts and targets animate `transform` and `opacity` only (plus the pop's blur), and\n *   both target looks set every property, so switching to reduced motion leaves no blur behind.\n * - Reduced motion still plays the session a step at a time, text whole and changes fading; the\n *   animated panes are `aria-hidden` and `<Transcript>` gives screen readers the script as text.\n */\nconst TIMING = {\n  /** Playback rate, as a multiple of the default pace (the `speed` prop). */\n  speed: 1,\n  // The script's pace at the default speed; `speed` divides these.\n  /** Between coming into view and the first keystroke, in ms. */\n  boot: 500,\n  /** Per typed character, in ms. */\n  key: 34,\n  /** Variation per keystroke, in ms; deterministic. */\n  jitter: 26,\n  /** Extra after a comma or full stop, in ms. */\n  comma: 90,\n  /** A finished prompt (or shell command) waiting before it's sent, in ms. */\n  send: 380,\n  /** Between a prompt landing and the agent's first move, in ms. */\n  think: 560,\n  /** How long a command runs when its step gives no `ms`, in ms. */\n  run: 900,\n  /** Between output lines landing, in ms. */\n  line: 26,\n  /** Cap on one burst's whole stagger, in ms. */\n  spread: 320,\n  /** Per word of a reply, in ms. */\n  word: 22,\n  /** Between steps, in ms. */\n  gap: 420,\n  /** Between a line writing a file and the app showing it, in ms. The reload sweep starts there. */\n  hmr: 140,\n  /** Between steps when reduced motion plays it step by step, in ms. */\n  stepped: 650,\n  // Not divided by `speed`.\n  /** Between clock readings, so spinners and timers move between keystrokes, in ms. */\n  tick: 100,\n  /** Per frame of the spinner, in ms of the session clock. */\n  spinner: 80,\n  /** A finished session waiting before `loop` restarts it, in ms. */\n  hold: 4000,\n  /** A line or prompt landing in the transcript, in seconds. */\n  rise: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },\n  /** Reduced motion: lines and changes appear, in seconds. */\n  fade: { duration: 0.2, ease: \"easeOut\" },\n  /** A change landing in the app (the target's `duration` and `bounce` props). */\n  pop: { type: \"spring\", visualDuration: 0.42, bounce: 0.22 },\n  /** The placeholder making way, in seconds. */\n  leave: { duration: 0.14, ease: \"easeOut\" },\n  /** The landing ring and line glow fading out, in seconds. */\n  flash: { duration: 0.9, ease: [0.22, 1, 0.36, 1] },\n  /** The address bar's reload sweep, in seconds. */\n  sweep: { duration: 0.42, ease: [0.22, 1, 0.36, 1] },\n  /** The sweep's fade, in seconds, held until 60% of the way. */\n  sweepFade: { duration: 0.6, times: [0, 0.6, 1] },\n  /** Hover highlight on and off, in seconds. */\n  link: { duration: 0.15, ease: \"easeOut\" },\n  /** The transcript following new lines, or scrolling to a pointed-at line. */\n  scroll: { type: \"spring\", visualDuration: 0.3, bounce: 0 },\n  /** One blink of a running command's dot, in seconds. */\n  blink: { duration: 0.9, ease: \"easeInOut\" },\n  /** The dot going solid when its command finishes, in seconds. */\n  dot: { duration: 0.15 },\n  /** One blink of the caret, in seconds: on for the first half, off for the second. */\n  caret: { duration: 1.05, times: [0, 0.5, 0.5, 1], ease: \"linear\" },\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * AGENT SESSION STORYBOARD\n *\n *  rest      a terminal beside the app it is about to change:\n *            an empty transcript over the input box, and the\n *            app in its before state\n *  view      a third of it in view, then 0.5s: the prompt types\n *            itself into the input box, 34ms a key with a\n *            little human jitter and a pause after commas\n *  send      0.38s after the last key the input clears and the\n *            prompt rises 8px into the transcript\n *  run       a command line appears with a blinking dot and a\n *            live timer under it; when it finishes the dot\n *            turns green (red if it failed) and the output\n *            lands in one burst, 26ms a line, capped at 0.32s\n *  land      a line that writes a file glows in the accent, the\n *            preview's address bar runs a reload sweep, and\n *            140ms later the change pops into the app (a 0.42s\n *            spring with a little give) inside a ring that\n *            fades over 0.9s. Cause, reload, effect\n *  reply     the agent's answer streams in a word at a time\n *  done      the status line reads \"Worked for 9s · 3 commands\"\n *            and the title bar offers Replay\n *  link      point at a change in the app and the line that made\n *            it lights up (scrolled into view if needed); point\n *            at that line and the change is ringed\n *  again     Replay, a new `script`, or `loop` start from the\n *            top with the app back in its before state\n *  sleep     off screen or in a hidden tab the clock stops and\n *            resumes where it was\n *  keys      Skip lands everything at once, Replay starts over;\n *            screen readers get the whole transcript as text\n *  reduced   the session still plays, a step at a time: each\n *            prompt, command and reply appears whole, a beat\n *            apart, and changes fade in without a pop or rise\n * ───────────────────────────────────────────────────────── */\n\nconst OFFSET = {\n  lift: 8, // px a sent prompt rises\n  drop: 6, // px an output line rises\n} as const;\n\nconst SPINNER = \"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\";\n\n/* ── Script ─────────────────────────────────────────────── */\n\nexport type AgentSessionTone = \"default\" | \"muted\" | \"added\" | \"removed\" | \"success\" | \"error\";\n\n/** One line of output: plain text, or text with a tone and the change it makes in the preview. */\nexport type AgentSessionLine = string | { text: string; tone?: AgentSessionTone; effect?: string };\n\nexport type AgentSessionStep =\n  /** You, typing to the agent. Types into the input box, then moves into the transcript. */\n  | { prompt: string }\n  /** The agent running a tool. `tool` defaults to \"Bash\", `ms` is how long it runs, `pace` spaces its output lines. */\n  | {\n      run: string;\n      tool?: string;\n      output?: AgentSessionLine[];\n      more?: number;\n      ms?: number;\n      pace?: number;\n      failed?: boolean;\n    }\n  /** The agent's reply, streamed a word at a time. `effect` lands when it finishes. */\n  | { say: string; effect?: string }\n  /** A command you type at a `$` prompt yourself, for plain CLI sessions. */\n  | { shell: string; output?: AgentSessionLine[]; more?: number; ms?: number; pace?: number; failed?: boolean };\n\ntype PlanLine = { text: string; tone: AgentSessionTone; effect?: string; at: number };\n\ntype PlanStep =\n  | { kind: \"prompt\"; text: string; keys: number[]; sent: number }\n  | {\n      kind: \"run\";\n      shell: boolean;\n      tool: string;\n      command: string;\n      keys: number[];\n      called: number;\n      done: number;\n      lines: PlanLine[];\n      more?: { count: number; at: number };\n      failed: boolean;\n    }\n  | { kind: \"say\"; parts: string[]; at: number[]; start: number };\n\ninterface Plan {\n  steps: PlanStep[];\n  /** Every moment the picture changes, sorted. */\n  events: number[];\n  /** When each change lands in the preview. */\n  effects: Map<string, number>;\n  total: number;\n  /** The first prompt's send, where the \"Worked for\" clock starts. */\n  begun: number;\n  commands: number;\n  agent: boolean;\n}\n\n// Same jitter on the server, the client and every replay.\nconst noise = (i: number) => {\n  const x = Math.sin(i * 12.9898 + 78.233) * 43758.5453;\n  return x - Math.floor(x);\n};\n\nfunction lineOf(line: AgentSessionLine): Omit<PlanLine, \"at\"> {\n  return typeof line === \"string\" ? { text: line, tone: \"default\" } : { tone: \"default\", ...line };\n}\n\nfunction buildPlan(script: AgentSessionStep[], speed: number, stepped: boolean): Plan {\n  const k = 1 / Math.max(0.1, speed);\n  const steps: PlanStep[] = [];\n  const marks: number[] = [0];\n  const effects = new Map<string, number>();\n  let begun = -1;\n  let commands = 0;\n  let t = TIMING.boot * k;\n\n  const land = (effect: string | undefined, at: number) => {\n    if (!effect) return;\n    const time = at + TIMING.hmr * k;\n    if (!effects.has(effect) || effects.get(effect)! > time) effects.set(effect, time);\n    marks.push(time);\n  };\n\n  const type = (text: string) => {\n    const keys: number[] = [];\n    for (let i = 0; i < text.length; i++) {\n      if (!stepped) {\n        const ch = text[i - 1];\n        t += (TIMING.key + (noise(i) - 0.5) * TIMING.jitter + (ch === \",\" || ch === \".\" ? TIMING.comma : 0)) * k;\n      }\n      keys.push(t);\n    }\n    marks.push(...keys);\n    return keys;\n  };\n\n  for (const step of script) {\n    if (\"prompt\" in step) {\n      const keys = type(step.prompt);\n      const sent = t + (stepped ? TIMING.stepped / 2 : TIMING.send) * k;\n      if (begun < 0) begun = sent;\n      steps.push({ kind: \"prompt\", text: step.prompt, keys, sent });\n      marks.push(sent);\n      t = sent + (stepped ? TIMING.stepped : TIMING.think) * k;\n    } else if (\"run\" in step || \"shell\" in step) {\n      const shell = \"shell\" in step;\n      const command = shell ? step.shell : step.run;\n      const keys = shell ? type(command) : [];\n      const called = t + (shell ? (stepped ? TIMING.stepped / 2 : TIMING.send) : 0) * k;\n      const done = called + (stepped ? Math.min(step.ms ?? TIMING.run, TIMING.stepped) : (step.ms ?? TIMING.run)) * k;\n      const raw = (step.output ?? []).map(lineOf);\n      // One quick burst by default; `pace` writes them one at a time, like files landing.\n      const gap = stepped ? 0 : (step.pace ?? Math.min(TIMING.line, TIMING.spread / Math.max(1, raw.length))) * k;\n      const lines = raw.map((line, i) => ({ ...line, at: done + i * gap }));\n      lines.forEach((line) => land(line.effect, line.at));\n      const more = step.more ? { count: step.more, at: done + raw.length * gap } : undefined;\n      commands++;\n      steps.push({\n        kind: \"run\",\n        shell,\n        tool: shell ? \"\" : (\"tool\" in step && step.tool) || \"Bash\",\n        command,\n        keys,\n        called,\n        done,\n        lines,\n        more,\n        failed: !!step.failed,\n      });\n      marks.push(called, done, ...lines.map((line) => line.at), more?.at ?? done);\n      t = (more?.at ?? lines.at(-1)?.at ?? done) + (stepped ? TIMING.stepped : TIMING.gap) * k;\n    } else {\n      const parts = step.say.match(/\\S+\\s*/g) ?? [step.say];\n      const start = t;\n      const at = parts.map((_, i) => (stepped ? start : start + i * TIMING.word * k));\n      steps.push({ kind: \"say\", parts, at, start });\n      marks.push(...at);\n      land(step.effect, at.at(-1) ?? start);\n      t = (at.at(-1) ?? start) + (stepped ? TIMING.stepped : TIMING.gap) * k;\n    }\n  }\n\n  const total = Math.max(0, ...marks) + 1;\n  // Clock readings, so spinners and timers move between keystrokes.\n  for (let tick = 0; tick < total; tick += TIMING.tick) marks.push(tick);\n  marks.push(total);\n  const events = [...new Set(marks.map((m) => Math.round(m)))].sort((a, b) => a - b);\n\n  return {\n    steps,\n    events,\n    effects,\n    total: events.at(-1) ?? 0,\n    begun: Math.max(0, begun),\n    commands,\n    agent: script.some((step) => \"prompt\" in step || \"say\" in step || \"run\" in step),\n  };\n}\n\nfunction nextEvent(events: number[], t: number) {\n  let lo = 0;\n  let hi = events.length;\n  while (lo < hi) {\n    const mid = (lo + hi) >> 1;\n    if (events[mid] <= t) lo = mid + 1;\n    else hi = mid;\n  }\n  return events[lo] ?? Infinity;\n}\n\n/* ── Store ──────────────────────────────────────────────── */\n\ntype State = {\n  /** Session clock, in ms. */\n  t: number;\n  /** The clock got here by a jump (Skip, Replay, a new script), so nothing animates on the way. */\n  jumped: boolean;\n  /** The change being pointed at, in either pane. */\n  hover: string | null;\n  /** Bumped on every restart, so a run completes once. */\n  run: number;\n};\n\nfunction createStore() {\n  let state: State = { t: 0, jumped: true, hover: null, run: 0 };\n  const listeners = new Set<() => void>();\n  const emit = () => listeners.forEach((listener) => listener());\n  return {\n    get: () => state,\n    seek(t: number, jumped: boolean) {\n      if (state.t === t && state.jumped === jumped) return;\n      state = { ...state, t, jumped };\n      emit();\n    },\n    restart() {\n      state = { ...state, t: 0, jumped: true, run: state.run + 1 };\n      emit();\n    },\n    point(hover: string | null) {\n      if (state.hover === hover) return;\n      state = { ...state, hover };\n      emit();\n    },\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n  };\n}\n\ntype Store = ReturnType<typeof createStore>;\n\nconst SessionContext = React.createContext<{\n  store: Store;\n  effects: Map<string, number>;\n  reduced: boolean;\n} | null>(null);\n\nfunction useSession(part: string) {\n  const session = React.useContext(SessionContext);\n  if (!session) throw new Error(`${part} must be used inside <AgentSession>.`);\n  return session;\n}\n\nfunction useLanded(effect: string) {\n  const { store, effects } = useSession(\"useAgentSessionEffect\");\n  const at = effects.get(effect) ?? Infinity;\n  return React.useSyncExternalStore(\n    store.subscribe,\n    () => store.get().t >= at,\n    () => false,\n  );\n}\n\n/**\n * True once the line with this `effect` has landed in the current run. For previews that change\n * in their own way, like a bar that fills or a number that counts up.\n */\nexport function useAgentSessionEffect(effect: string) {\n  return useLanded(effect);\n}\n\nconst subscribeNever = () => () => {};\n\nfunction usePageVisible() {\n  return React.useSyncExternalStore(\n    (onChange) => {\n      document.addEventListener(\"visibilitychange\", onChange);\n      return () => document.removeEventListener(\"visibilitychange\", onChange);\n    },\n    () => document.visibilityState === \"visible\",\n    () => true,\n  );\n}\n\n/* ── Look ───────────────────────────────────────────────── */\n\nconst THEMES = {\n  dark: {\n    window: \"bg-zinc-950 text-zinc-300 ring-white/10 shadow-[0_24px_48px_-28px_rgb(0_0_0/0.55)]\",\n    bar: \"border-white/[0.07]\",\n    title: \"text-zinc-400\",\n    strong: \"text-zinc-50\",\n    muted: \"text-zinc-500\",\n    band: \"bg-white/[0.06]\",\n    input: \"border-white/10 bg-white/[0.03]\",\n    button: \"text-zinc-400 hover:bg-white/[0.08] hover:text-zinc-100 focus-visible:ring-white/30\",\n    lights: [\"bg-[#ff5f57]\", \"bg-[#febc2e]\", \"bg-[#28c840]\"],\n    tones: {\n      default: \"text-zinc-300\",\n      muted: \"text-zinc-500\",\n      added: \"text-emerald-300\",\n      removed: \"text-rose-300\",\n      success: \"text-emerald-400\",\n      error: \"text-rose-400\",\n    },\n    dot: { idle: \"bg-zinc-500\", ok: \"bg-emerald-400\", failed: \"bg-rose-400\", say: \"bg-zinc-100\" },\n  },\n  auto: {\n    window:\n      \"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\",\n    bar: \"border-zinc-950/[0.07] dark:border-white/[0.07]\",\n    title: \"text-zinc-500 dark:text-zinc-400\",\n    strong: \"text-zinc-950 dark:text-zinc-50\",\n    muted: \"text-zinc-400 dark:text-zinc-500\",\n    band: \"bg-zinc-950/[0.04] dark:bg-white/[0.06]\",\n    input: \"border-zinc-950/10 bg-zinc-950/[0.02] dark:border-white/10 dark:bg-white/[0.03]\",\n    button:\n      \"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\",\n    lights: [\"bg-zinc-200 dark:bg-zinc-700\", \"bg-zinc-200 dark:bg-zinc-700\", \"bg-zinc-200 dark:bg-zinc-700\"],\n    tones: {\n      default: \"text-zinc-700 dark:text-zinc-300\",\n      muted: \"text-zinc-400 dark:text-zinc-500\",\n      added: \"text-emerald-700 dark:text-emerald-300\",\n      removed: \"text-rose-600 dark:text-rose-300\",\n      success: \"text-emerald-600 dark:text-emerald-400\",\n      error: \"text-rose-600 dark:text-rose-400\",\n    },\n    dot: {\n      idle: \"bg-zinc-400 dark:bg-zinc-500\",\n      ok: \"bg-emerald-500 dark:bg-emerald-400\",\n      failed: \"bg-rose-500 dark:bg-rose-400\",\n      say: \"bg-zinc-900 dark:bg-zinc-100\",\n    },\n  },\n} as const;\n\ntype Theme = (typeof THEMES)[keyof typeof THEMES];\n\nconst ACCENT = \"var(--agent-session-accent)\";\n\n/* ── AgentSession ───────────────────────────────────────── */\n\nexport interface AgentSessionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** What happens, in order: prompts you type, commands the agent runs with their output, its replies, and shell commands. */\n  script: AgentSessionStep[];\n  /** 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. */\n  children?: React.ReactNode;\n  /** Text in the terminal's title bar. */\n  title?: string;\n  /** Address shown in the preview window's bar. */\n  url?: string;\n  /** Start by itself once a third of it is in view. Off, it waits for the Play button. */\n  autoPlay?: boolean;\n  /** Stop the clock where it is. Off screen and in hidden tabs it stops by itself. */\n  paused?: boolean;\n  /** Play again from the top, after a 4s pause, forever or this many more times. */\n  loop?: boolean | number;\n  /** Playback rate. 2 plays twice as fast. */\n  speed?: number;\n  /** \"dark\" is a dark terminal in both color schemes; \"auto\" is light in light mode and dark in dark mode. */\n  theme?: \"dark\" | \"auto\";\n  /** Placeholder in the input box before anything is typed. */\n  placeholder?: string;\n  /** Called when a run finishes. */\n  onComplete?: () => void;\n  /** Called with the effect's name each time a change lands in the preview. */\n  onEffect?: (effect: string) => void;\n}\n\n/**\n * A coding agent in its terminal beside the app it's changing. Lines that write files land in the\n * preview as they print, and pointing at either half lights up the other.\n */\nexport function AgentSession({\n  script,\n  children,\n  title = \"agent\",\n  url = \"localhost:3000\",\n  autoPlay = true,\n  paused = false,\n  loop = false,\n  speed = TIMING.speed,\n  theme = \"dark\",\n  placeholder = \"Ask the agent to change something\",\n  onComplete,\n  onEffect,\n  className,\n  style,\n  ...props\n}: AgentSessionProps) {\n  // useReducedMotion is already true in the hydration render, so reduced markup waits for hydration.\n  const hydrated = React.useSyncExternalStore(subscribeNever, () => true, () => false);\n  const reduced = !!useReducedMotion() && hydrated;\n  const plan = React.useMemo(() => buildPlan(script, speed, reduced), [script, speed, reduced]);\n  const [store] = React.useState(createStore);\n  const state = React.useSyncExternalStore(store.subscribe, store.get, store.get);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const seen = useInView(rootRef, { once: true, amount: 0.33 });\n  const inView = useInView(rootRef, { amount: 0.05 });\n  const visible = usePageVisible();\n  const [pressedPlay, setPressedPlay] = React.useState(false);\n  const started = autoPlay ? seen || pressedPlay : pressedPlay;\n  const running = started && inView && visible && !paused;\n\n  // Callbacks change every render; the clock shouldn't restart for that.\n  const handlers = React.useRef({ onComplete, onEffect });\n  React.useEffect(() => {\n    handlers.current = { onComplete, onEffect };\n  });\n\n  // A new script (or speed) starts over with the app in its before state.\n  const firstPlan = React.useRef(true);\n  React.useEffect(() => {\n    if (firstPlan.current) {\n      firstPlan.current = false;\n      return;\n    }\n    store.restart();\n  }, [plan, store]);\n\n  const loops = React.useRef(0);\n  const completed = React.useRef(-1);\n  React.useEffect(() => {\n    if (!running) return;\n    let timer = 0;\n    const advance = () => {\n      const { t, run } = store.get();\n      if (t >= plan.total) {\n        if (completed.current !== run) {\n          completed.current = run;\n          handlers.current.onComplete?.();\n        }\n        const more = loop === true || (typeof loop === \"number\" && loops.current < loop);\n        if (more) {\n          // The restart bumps `run`, which starts this effect over from the top.\n          timer = window.setTimeout(() => {\n            loops.current++;\n            store.restart();\n          }, TIMING.hold);\n        }\n        return;\n      }\n      const next = nextEvent(plan.events, t);\n      timer = window.setTimeout(() => {\n        store.seek(next, false);\n        for (const [effect, at] of plan.effects) {\n          if (at > t && at <= next) handlers.current.onEffect?.(effect);\n        }\n        advance();\n      }, next - t);\n    };\n    advance();\n    return () => window.clearTimeout(timer);\n  }, [running, plan, store, loop, state.run]);\n\n  const done = state.t >= plan.total;\n  const skip = () => store.seek(plan.total, true);\n  const replay = () => {\n    loops.current = 0;\n    store.restart();\n    setPressedPlay(true);\n  };\n\n  const look = THEMES[theme];\n  const context = React.useMemo(() => ({ store, effects: plan.effects, reduced }), [store, plan.effects, reduced]);\n  const control = !started ? \"play\" : done ? \"replay\" : \"skip\";\n\n  return (\n    <SessionContext.Provider value={context}>\n      <div\n        ref={rootRef}\n        className={cn(\n          \"@container/agent-session w-full [--agent-session-accent:#8b5cf6] dark:[--agent-session-accent:#a78bfa]\",\n          className,\n        )}\n        style={style}\n        {...props}\n      >\n        <div\n          className={cn(\n            \"grid gap-3\",\n            children != null && \"@[44rem]/agent-session:grid-cols-[minmax(0,1.08fr)_minmax(0,1fr)]\",\n          )}\n        >\n          <Terminal\n            plan={plan}\n            state={state}\n            look={look}\n            title={title}\n            placeholder={placeholder}\n            control={control}\n            onControl={() => (control === \"skip\" ? skip() : control === \"replay\" ? replay() : setPressedPlay(true))}\n            store={store}\n            reduced={reduced}\n          />\n          {children != null && (\n            <Preview url={url} plan={plan} t={state.t} jumped={state.jumped} reduced={reduced}>\n              {children}\n            </Preview>\n          )}\n        </div>\n        <Transcript script={script} done={done} />\n      </div>\n    </SessionContext.Provider>\n  );\n}\n\n/* ── Terminal ───────────────────────────────────────────── */\n// The animated transcript is hidden from screen readers; <Transcript> reads the same script as text.\n\nfunction Terminal({\n  plan,\n  state,\n  look,\n  title,\n  placeholder,\n  control,\n  onControl,\n  store,\n  reduced,\n}: {\n  plan: Plan;\n  state: State;\n  look: Theme;\n  title: string;\n  placeholder: string;\n  control: \"play\" | \"skip\" | \"replay\";\n  onControl: () => void;\n  store: Store;\n  reduced: boolean;\n}) {\n  const { t, jumped, hover } = state;\n  const instant = jumped;\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const stick = React.useRef(true);\n  const auto = React.useRef<ReturnType<typeof animate> | null>(null);\n\n  // Follow new lines to the bottom unless the reader scrolled up to look at something.\n  React.useLayoutEffect(() => {\n    const el = scrollRef.current;\n    if (!el || !stick.current) return;\n    const target = el.scrollHeight - el.clientHeight;\n    if (Math.abs(el.scrollTop - target) < 1) return;\n    auto.current?.stop();\n    if (instant || reduced) {\n      el.scrollTop = target;\n      return;\n    }\n    auto.current = animate(el.scrollTop, target, {\n      ...TIMING.scroll,\n      onUpdate: (v) => (el.scrollTop = v),\n      onComplete: () => (auto.current = null),\n    });\n  }, [t, instant, reduced]);\n\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    const onIntent = () => auto.current?.stop();\n    const onScroll = () => {\n      if (auto.current) return;\n      stick.current = el.scrollHeight - el.clientHeight - el.scrollTop < 24;\n    };\n    el.addEventListener(\"wheel\", onIntent, { passive: true });\n    el.addEventListener(\"touchstart\", onIntent, { passive: true });\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    return () => {\n      el.removeEventListener(\"wheel\", onIntent);\n      el.removeEventListener(\"touchstart\", onIntent);\n      el.removeEventListener(\"scroll\", onScroll);\n    };\n  }, []);\n\n  // Pointing at a change in the app brings the line that made it into view.\n  React.useEffect(() => {\n    const el = scrollRef.current;\n    if (!el || !hover) return;\n    const line = el.querySelector<HTMLElement>(`[data-effect=\"${CSS.escape(hover)}\"]`);\n    if (!line) return;\n    const box = el.getBoundingClientRect();\n    const rect = line.getBoundingClientRect();\n    if (rect.top >= box.top && rect.bottom <= box.bottom) return;\n    const top = rect.top - box.top + el.scrollTop;\n    auto.current?.stop();\n    stick.current = false;\n    const target = Math.max(0, Math.min(top - el.clientHeight / 2, el.scrollHeight - el.clientHeight));\n    if (reduced) el.scrollTop = target;\n    else\n      auto.current = animate(el.scrollTop, target, {\n        ...TIMING.scroll,\n        onUpdate: (v) => (el.scrollTop = v),\n        onComplete: () => (auto.current = null),\n      });\n  }, [hover, reduced]);\n\n  const spinner = SPINNER[Math.floor(t / TIMING.spinner) % SPINNER.length];\n  const typing = plan.steps.find(\n    (step): step is Extract<PlanStep, { kind: \"prompt\" }> =>\n      step.kind === \"prompt\" && step.keys.length > 0 && t >= step.keys[0] && t < step.sent,\n  );\n  const active = plan.steps.find(\n    (step): step is Extract<PlanStep, { kind: \"run\" }> => step.kind === \"run\" && t >= step.called && t < step.done,\n  );\n  const done = t >= plan.total;\n  const begun = t >= plan.begun && plan.steps.some((step) => step.kind === \"prompt\");\n\n  const enter = (rise: number) =>\n    instant\n      ? false\n      : reduced\n        ? { opacity: 0 }\n        : { opacity: 0, transform: `translateY(${rise}px)` };\n\n  return (\n    <div\n      className={cn(\n        \"flex h-[var(--agent-session-height,24rem)] min-w-0 flex-col overflow-hidden rounded-xl ring-1\",\n        look.window,\n      )}\n    >\n      <div className={cn(\"relative flex h-9 shrink-0 items-center border-b px-3\", look.bar)}>\n        <div className=\"flex gap-1.5\" aria-hidden>\n          {look.lights.map((light, i) => (\n            <span key={i} className={cn(\"size-2.5 rounded-full\", light)} />\n          ))}\n        </div>\n        <p className={cn(\"pointer-events-none absolute inset-x-24 truncate text-center text-xs\", look.title)}>\n          {title}\n        </p>\n        <button\n          type=\"button\"\n          onClick={onControl}\n          className={cn(\n            \"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\",\n            look.button,\n          )}\n        >\n          <ControlIcon control={control} />\n          {control === \"play\" ? \"Play\" : control === \"skip\" ? \"Skip\" : \"Replay\"}\n        </button>\n      </div>\n\n      <div\n        ref={scrollRef}\n        aria-hidden\n        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\"\n      >\n        <div className=\"flex flex-col gap-3\">\n          {plan.steps.map((step, i) => {\n            if (step.kind === \"prompt\") {\n              if (t < step.sent) return null;\n              return (\n                <motion.div\n                  key={i}\n                  initial={enter(OFFSET.lift)}\n                  animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n                  transition={reduced ? TIMING.fade : TIMING.rise}\n                  className={cn(\"-mx-2 flex gap-2 rounded-md px-2 py-1\", look.band, look.strong)}\n                >\n                  <span className={look.muted}>&gt;</span>\n                  <span className=\"min-w-0 whitespace-pre-wrap break-words\">{step.text}</span>\n                </motion.div>\n              );\n            }\n\n            if (step.kind === \"run\") {\n              const startAt = step.shell ? (step.keys[0] ?? step.called) : step.called;\n              if (t < startAt) return null;\n              const typed = step.shell ? step.command.slice(0, step.keys.filter((key) => key <= t).length) : step.command;\n              const isTyping = step.shell && t < step.called;\n              const isRunning = t >= step.called && t < step.done;\n              const status = t < step.done ? \"idle\" : step.failed ? \"failed\" : \"ok\";\n              const lines = step.lines.filter((line) => line.at <= t);\n              return (\n                <div key={i} className=\"min-w-0\">\n                  <motion.div\n                    initial={step.shell ? false : enter(OFFSET.drop)}\n                    animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n                    transition={reduced ? TIMING.fade : TIMING.rise}\n                    className=\"flex min-w-0 items-baseline gap-2\"\n                  >\n                    {step.shell ? (\n                      <span className={look.muted}>$</span>\n                    ) : (\n                      <StatusDot className={look.dot[status]} blink={isRunning && !reduced} />\n                    )}\n                    <span className=\"min-w-0 truncate whitespace-pre\">\n                      {step.shell ? (\n                        <span className={look.strong}>{typed}</span>\n                      ) : (\n                        <>\n                          <span className={cn(\"font-semibold\", look.strong)}>{step.tool}</span>\n                          <span className={look.muted}>(</span>\n                          {step.command}\n                          <span className={look.muted}>)</span>\n                        </>\n                      )}\n                      {isTyping && <Caret solid />}\n                    </span>\n                  </motion.div>\n\n                  {isRunning && (\n                    <div className={cn(\"flex gap-2 pl-[1.1rem]\", look.muted)}>\n                      <span>{reduced ? \"…\" : spinner}</span>\n                      <span className=\"tabular-nums\">\n                        Running {((t - step.called) / 1000).toFixed(1)}s\n                      </span>\n                    </div>\n                  )}\n\n                  {(lines.length > 0 || (step.more && t >= step.more.at)) && (\n                    <div className=\"grid grid-cols-[1.1rem_minmax(0,1fr)]\">\n                      {lines.map((line, j) => (\n                        <OutputLine\n                          key={j}\n                          line={line}\n                          first={j === 0}\n                          look={look}\n                          enter={enter(OFFSET.drop)}\n                          instant={instant}\n                          reduced={reduced}\n                          linked={!!line.effect && hover === line.effect}\n                          onPoint={(on) => line.effect && store.point(on ? line.effect : null)}\n                        />\n                      ))}\n                      {step.more && t >= step.more.at && (\n                        <motion.div\n                          initial={enter(OFFSET.drop)}\n                          animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n                          transition={reduced ? TIMING.fade : TIMING.rise}\n                          className={cn(\"col-start-2\", look.muted)}\n                        >\n                          … +{step.more.count} lines\n                        </motion.div>\n                      )}\n                    </div>\n                  )}\n                </div>\n              );\n            }\n\n            const shown = step.at.filter((at) => at <= t).length;\n            if (shown === 0) return null;\n            return (\n              <div key={i} className={cn(\"flex min-w-0 items-baseline gap-2\", look.strong)}>\n                <StatusDot className={look.dot.say} />\n                <p className=\"min-w-0 whitespace-pre-wrap break-words font-sans text-[13px] leading-relaxed\">\n                  {step.parts.slice(0, shown).join(\"\")}\n                </p>\n              </div>\n            );\n          })}\n        </div>\n      </div>\n\n      {plan.agent && (\n        <div className={cn(\"shrink-0 border-t px-3 pt-2.5 pb-2\", look.bar)} aria-hidden>\n          <div\n            className={cn(\n              \"flex h-9 min-w-0 items-center gap-2 rounded-lg border px-3 font-mono text-[12px]\",\n              look.input,\n            )}\n          >\n            <span className={look.muted}>&gt;</span>\n            {typing ? (\n              <span className={cn(\"min-w-0 truncate whitespace-pre\", look.strong)}>\n                {typing.text.slice(0, typing.keys.filter((key) => key <= t).length)}\n                <Caret solid />\n              </span>\n            ) : (\n              <span className={cn(\"min-w-0 truncate\", look.muted)}>\n                {!begun && <Caret solid={reduced} />}\n                {!begun && <span className=\"ml-1\">{placeholder}</span>}\n              </span>\n            )}\n          </div>\n          <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)}>\n            {done && begun ? (\n              <>\n                <Check />\n                <span className=\"truncate\">\n                  Worked for {Math.max(1, Math.round((plan.total - plan.begun) / 1000))}s · {plan.commands}{\" \"}\n                  {plan.commands === 1 ? \"command\" : \"commands\"}\n                </span>\n              </>\n            ) : begun ? (\n              <>\n                <span className=\"font-mono\">{reduced ? \"…\" : spinner}</span>\n                <span className=\"truncate\">\n                  {active ? `Running ${active.tool}` : \"Thinking\"} · {Math.floor((t - plan.begun) / 1000)}s\n                </span>\n              </>\n            ) : null}\n          </p>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction OutputLine({\n  line,\n  first,\n  look,\n  enter,\n  instant,\n  reduced,\n  linked,\n  onPoint,\n}: {\n  line: PlanLine;\n  first: boolean;\n  look: Theme;\n  enter: false | { opacity: number; transform?: string };\n  instant: boolean;\n  reduced: boolean;\n  linked: boolean;\n  onPoint: (on: boolean) => void;\n}) {\n  return (\n    <>\n      <span className={cn(\"select-none\", look.muted)}>{first ? \"⎿\" : \"\"}</span>\n      <motion.div\n        data-effect={line.effect}\n        initial={enter}\n        animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n        transition={reduced ? TIMING.fade : TIMING.rise}\n        onPointerEnter={(e) => e.pointerType === \"mouse\" && onPoint(true)}\n        onPointerLeave={(e) => e.pointerType === \"mouse\" && onPoint(false)}\n        className={cn(\"relative -mx-1.5 min-w-0 rounded px-1.5\", look.tones[line.tone])}\n      >\n        {line.effect && (\n          <>\n            {/* The glow it lands with, then the highlight while its change is pointed at. */}\n            <motion.span\n              aria-hidden\n              initial={instant ? false : { opacity: 0.7 }}\n              animate={{ opacity: 0 }}\n              transition={TIMING.flash}\n              className=\"pointer-events-none absolute inset-0 rounded\"\n              style={{ background: `color-mix(in oklab, ${ACCENT} 32%, transparent)` }}\n            />\n            <motion.span\n              aria-hidden\n              initial={false}\n              animate={{ opacity: linked ? 1 : 0 }}\n              transition={TIMING.link}\n              className=\"pointer-events-none absolute inset-0 rounded\"\n              style={{\n                background: `color-mix(in oklab, ${ACCENT} 20%, transparent)`,\n                boxShadow: `inset 2px 0 0 ${ACCENT}`,\n              }}\n            />\n          </>\n        )}\n        <span className=\"relative block truncate whitespace-pre\">{line.text}</span>\n      </motion.div>\n    </>\n  );\n}\n\nfunction StatusDot({ className, blink = false }: { className: string; blink?: boolean }) {\n  return (\n    <motion.span\n      aria-hidden\n      animate={blink ? { opacity: [1, 0.25, 1] } : { opacity: 1 }}\n      transition={blink ? { ...TIMING.blink, repeat: Infinity } : TIMING.dot}\n      className={cn(\"inline-block size-[7px] shrink-0 translate-y-[-1px] rounded-full transition-colors\", className)}\n    />\n  );\n}\n\nfunction Caret({ solid = false }: { solid?: boolean }) {\n  return (\n    <motion.span\n      aria-hidden\n      animate={solid ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}\n      transition={\n        solid\n          ? { duration: 0 }\n          : {\n              duration: TIMING.caret.duration,\n              repeat: Infinity,\n              times: [...TIMING.caret.times],\n              ease: TIMING.caret.ease,\n            }\n      }\n      className=\"ml-px inline-block h-[1.15em] w-[0.5em] translate-y-[0.2em] bg-current opacity-80\"\n    />\n  );\n}\n\nfunction ControlIcon({ control }: { control: \"play\" | \"skip\" | \"replay\" }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} aria-hidden>\n      {control === \"play\" ? (\n        <path d=\"M5 3.5v9l7-4.5-7-4.5Z\" fill=\"currentColor\" stroke=\"none\" />\n      ) : control === \"skip\" ? (\n        <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\" />\n      ) : (\n        <path d=\"M3 8a5 5 0 1 0 1.6-3.7M3 2.5v2.8h2.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n      )}\n    </svg>\n  );\n}\n\nfunction Check() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3 shrink-0\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} aria-hidden>\n      <path d=\"m3.5 8.5 3 3 6-7\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\n/* ── Preview ────────────────────────────────────────────── */\n// A browser window around the app. Each landing runs a reload sweep across its address bar.\n\nfunction Preview({\n  url,\n  plan,\n  t,\n  jumped,\n  reduced,\n  children,\n}: {\n  url: string;\n  plan: Plan;\n  t: number;\n  jumped: boolean;\n  reduced: boolean;\n  children: React.ReactNode;\n}) {\n  // The latest file write, which kicks off a reload `hmr` ms before its change shows.\n  let reload = -1;\n  for (const at of plan.effects.values()) {\n    const start = at - TIMING.hmr;\n    if (start <= t && start > reload) reload = start;\n  }\n\n  return (\n    <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\">\n      <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]\">\n        <div className=\"absolute left-3 flex gap-1.5\" aria-hidden>\n          <span className=\"size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700\" />\n          <span className=\"size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700\" />\n          <span className=\"size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700\" />\n        </div>\n        <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\">\n          {url}\n        </span>\n        {reload >= 0 && !jumped && !reduced && (\n          <motion.span\n            key={reload}\n            aria-hidden\n            initial={{ transform: \"scaleX(0)\", opacity: 1 }}\n            animate={{ transform: \"scaleX(1)\", opacity: [1, 1, 0] }}\n            transition={{\n              ...TIMING.sweep,\n              opacity: { duration: TIMING.sweepFade.duration, times: [...TIMING.sweepFade.times] },\n            }}\n            className=\"absolute inset-x-0 -bottom-px h-0.5 origin-left\"\n            style={{ background: ACCENT }}\n          />\n        )}\n      </div>\n      <div className=\"relative min-h-0 flex-1 overflow-hidden\">{children}</div>\n    </div>\n  );\n}\n\n/* ── AgentSessionTarget ─────────────────────────────────── */\n\nexport interface AgentSessionTargetProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The name a script line gives in its `effect`. The target changes when that line lands. */\n  effect: string;\n  /** Shown until the change lands, like a placeholder icon or the old label. Without it the target waits invisible, keeping its space. */\n  fallback?: React.ReactNode;\n  /** The changed content. A function gets `landed` for previews that change in their own way. */\n  children: React.ReactNode | ((landed: boolean) => React.ReactNode);\n  /** How the content arrives: a springy pop, a plain fade, or no motion of its own (the ring still flashes). */\n  enter?: \"pop\" | \"fade\" | \"none\";\n  /** How long the pop takes to land, in seconds. Only for `enter=\"pop\"`. */\n  duration?: number;\n  /** How much the pop overshoots and settles, from 0 (none) up. Only for `enter=\"pop\"`. */\n  bounce?: number;\n}\n\n/**\n * A part of the preview that a script line changes. Swaps `fallback` for its children when the line\n * lands, with a ring around it, and links to that line on hover.\n */\nexport function AgentSessionTarget({\n  effect,\n  fallback,\n  children,\n  enter = \"pop\",\n  duration = TIMING.pop.visualDuration,\n  bounce = TIMING.pop.bounce,\n  className,\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: AgentSessionTargetProps) {\n  const { store, reduced } = useSession(\"<AgentSessionTarget>\");\n  const landed = useLanded(effect);\n  const linked = React.useSyncExternalStore(\n    store.subscribe,\n    () => store.get().hover === effect,\n    () => false,\n  );\n  // A landing by Skip, Replay or a new script doesn't animate.\n  const instant = React.useSyncExternalStore(\n    store.subscribe,\n    () => store.get().jumped,\n    () => true,\n  );\n\n  const motionOf = enter === \"none\" ? null : enter === \"fade\" || reduced ? \"fade\" : \"pop\";\n  // Both looks set every property, so switching to reduced motion after hydration leaves no blur behind.\n  const hidden =\n    motionOf === \"pop\"\n      ? { opacity: 0, transform: \"scale(0.9)\", filter: \"blur(4px)\" }\n      : { opacity: 0, transform: \"scale(1)\", filter: \"blur(0px)\" };\n  const shown = { opacity: 1, transform: \"scale(1)\", filter: \"blur(0px)\" };\n\n  return (\n    <div\n      className={cn(\"relative grid [&>*]:[grid-area:1/1]\", className)}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"mouse\" && landed) store.point(effect);\n        onPointerEnter?.(e);\n      }}\n      onPointerLeave={(e) => {\n        if (e.pointerType === \"mouse\") store.point(null);\n        onPointerLeave?.(e);\n      }}\n      {...props}\n    >\n      {fallback != null && (\n        <motion.div\n          aria-hidden={landed}\n          initial={false}\n          animate={landed ? { opacity: 0, transform: \"scale(0.96)\" } : { opacity: 1, transform: \"scale(1)\" }}\n          transition={instant ? { duration: 0 } : TIMING.leave}\n          className={cn(\"min-w-0\", landed && \"pointer-events-none\")}\n        >\n          {fallback}\n        </motion.div>\n      )}\n      {motionOf == null ? (\n        <div className=\"min-w-0\">{typeof children === \"function\" ? children(landed) : children}</div>\n      ) : (\n        <motion.div\n          aria-hidden={!landed}\n          initial={false}\n          animate={landed ? shown : hidden}\n          transition={\n            instant ? { duration: 0 } : motionOf === \"pop\" ? { ...TIMING.pop, visualDuration: duration, bounce } : TIMING.fade\n          }\n          className={cn(\"min-w-0\", !landed && \"pointer-events-none\")}\n        >\n          {typeof children === \"function\" ? children(landed) : children}\n        </motion.div>\n      )}\n      {landed && (\n        <motion.span\n          aria-hidden\n          initial={instant ? false : { opacity: 1, transform: \"scale(0.97)\" }}\n          animate={{ opacity: 0, transform: \"scale(1.04)\" }}\n          transition={TIMING.flash}\n          className=\"pointer-events-none absolute -inset-1 rounded-[var(--agent-session-ring-radius,10px)]\"\n          style={{ boxShadow: `0 0 0 2px ${ACCENT}` }}\n        />\n      )}\n      <motion.span\n        aria-hidden\n        initial={false}\n        animate={{ opacity: linked && landed ? 1 : 0 }}\n        transition={TIMING.link}\n        className=\"pointer-events-none absolute -inset-1 rounded-[var(--agent-session-ring-radius,10px)]\"\n        style={{\n          boxShadow: `0 0 0 2px ${ACCENT}`,\n          background: `color-mix(in oklab, ${ACCENT} 8%, transparent)`,\n        }}\n      />\n    </div>\n  );\n}\n\n/* ── Transcript ─────────────────────────────────────────── */\n// The whole session as text for screen readers, with a polite note when a run finishes.\n\nfunction Transcript({ script, done }: { script: AgentSessionStep[]; done: boolean }) {\n  const text = (line: AgentSessionLine) => (typeof line === \"string\" ? line : line.text);\n  const reply = [...script].reverse().find((step): step is { say: string } => \"say\" in step);\n  return (\n    <div className=\"sr-only\">\n      <ol>\n        {script.map((step, i) => (\n          <li key={i}>\n            {\"prompt\" in step\n              ? `You: ${step.prompt}`\n              : \"say\" in step\n                ? `Agent: ${step.say}`\n                : \"run\" in step\n                  ? `Ran ${step.tool ?? \"Bash\"}: ${step.run}. ${(step.output ?? []).map(text).join(\". \")}`\n                  : `$ ${step.shell}. ${(step.output ?? []).map(text).join(\". \")}`}\n          </li>\n        ))}\n      </ol>\n      <p aria-live=\"polite\">{done ? `Session finished.${reply ? ` ${reply.say}` : \"\"}` : \"\"}</p>\n    </div>\n  );\n}\n"
    }
  ]
}