SLOPSHOPPER

replayio

Use Replay.io Pro from Claude Code with Replay Chromium recordings, inline live and scrubbable browser players, MP4 evidence, Replay MCP debugging, and…

newrowsguardcommandtoaststatus
v0.2.0MITupdated 2026-10-15replayio/plugins/claude-code/replayio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replayio
› fix the failing auth test and add an audit log call ● replayio: replayio: live relay did not start: relay exited before listening: ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /replayio ⎿ replayio: The Replay live relay is not running: relay exited before listening: . ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Replay.io Plugins

This repository is a shadcn GitHub registry for Replay.io agent and editor plugin bundles.

Install a bundle with shadcn add:

npx shadcn@latest add replayio/plugins/codex
npx shadcn@latest add replayio/plugins/codex-pro
npx shadcn@latest add replayio/plugins/cursor
npx shadcn@latest add replayio/plugins/claude-code
npx shadcn@latest add replayio/plugins/claude-pro
npx shadcn@latest add replayio/plugins/opencode
npx shadcn@latest add replayio/plugins/obvious-ai

codex and claude-code are Replay QA for production, staging, and PR preview QA runs. codex-pro and claude-pro are direct Replay.io dev tools bundles with Replay MCP, Replay Chromium, worker/critic proof-loop prompts, browser lifecycle screencast capture, ffmpeg-backed MP4 output, side-by-side video stitching, upload hooks, and MP4 video embedding.

Install the Codex bundles as a Codex plugin marketplace:

codex plugin marketplace add https://github.com/replayio/plugins --ref main
codex plugin add replay-qa@replayio-plugins
codex plugin add replayio@replayio-plugins

If you use sparse checkout when adding the marketplace, include both the marketplace manifest and the plugin bundle path:

codex plugin marketplace add https://github.com/replayio/plugins \
  --ref main \
  --sparse .agents/plugins/marketplace.json \
  --sparse codex/replay-qa \
  --sparse codex/replayio

Install the Claude Code plugins from the Claude Code plugin marketplace:

claude plugin marketplace add replayio/plugins
claude plugin install replay-qa@replayio
claude plugin install replayio@replayio

Registry Items

ItemInstalls
codexCodex Replay QA app installed to .codex-plugin/, skills/replay-qa/, assets/, and packaged scripts for project setup, status, bugs, journeys, test runs, explorations, and fix verification.
codex-proCodex Replay.io Pro dev tools installed to .codex-plugin/, .mcp.json, .app.json, agents/openai.yaml, hooks.json, scripts/, skills/replayio/, and assets/, with worker/critic proof-loop prompts, browser-open/browser-close lifecycle scripts, a side-by-side stitch helper, and hooks for screencast capture, ffmpeg-backed MP4 output, and Replay uploads.
cursorCursor plugin bundle installed to .cursor-plugin/, skills/, assets/, hooks, scripts, MCP config, and worker/critic proof-loop prompts.
claude-codeClaude Code Replay QA plugin installed to .claude/skills/replay-qa/, including plugin commands and script-backed skill helpers.
claude-proClaude Code Replay.io Pro plugin installed to .claude/skills/replayio/, .claude/agents/, hooks, scripts, MCP config, commands, and Replay Pro skill guidance.
opencodeOpenCode plugin bundle installed to .opencode/plugins/, .opencode/agent/, AGENTS.md, and opencode.json, including worker/critic proof-loop subagents.
obvious-aiSkills-only Obvious bundle installed to .obvious/skills/replay-qa-api/SKILL.md, including direct Replay QA API, Replay CLI token fallback, artifact, and project-reuse guidance.

The registry reads generated source files from the top-level platform directories, but each item installs into the platform-specific destination paths that plugin hosts expect.

Validate

Validate the registry before publishing changes:

npx shadcn@latest registry validate registry.json
Source 10 files
hooks/register.tsx 488 lines
1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4import { closedSessions, endCwd, openedSessions, parseBrowserCommands, withOutput, type BrowserCommand } from './detect.ts'
5import { registerMcpCards } from './mcp/render.tsx'
6
7// Inline Replay players: when Claude opens a Replay / playwright-cli browser
8// from Bash, the relay injects Replay's tracer into the page, records the rrweb
9// stream, and a headless renderer plays it into PNG frames that this module
10// shows as an Image in place of that Bash call's result, live while the browser
11// runs, then scrubbable (a seek bar, play/pause, ±10s) once it closes.
12// Recordings are kept on disk, so the players stay in the transcript's
13// scrollback across restarts, and Save video writes an .mp4 + .rrweb.json.
14
15type Relay = { port: number; token: string; dataDir: string }
16
17type RecordingInfo = {
18  id: string
19  session: string
20  status: 'live' | 'ended'
21  url: string | null
22  eventCount: number
23  durationMs: number
24  startedAt: number
25  lastError: string | null
26  /** The Replay Chromium recordings made while the session ran (none in another browser). */
27  replay: ReplayLink[]
28}
29
30type ReplayLink = {
31  id: string
32  uri: string | null
33  recordingStatus: string | null
34  uploadStatus: string | null
35  url: string
36}
37
38type ExportJob = { status: 'running' | 'done' | 'failed'; progress: number; files: string[]; error: string | null }
39
40type FrameInfo = {
41  file: string
42  generation: number
43  width: number
44  height: number
45  mode: 'live' | 'replay'
46  status: 'playing' | 'paused' | 'finished' | 'stopped'
47  currentMs: number
48  totalMs: number
49  /** When `currentMs` was true, on the relay's clock (the same machine's). */
50  at: number
51  /** Bumped by every seek, play and pause. */
52  seq: number
53  recording: RecordingInfo | null
54  export: ExportJob | null
55}
56
57type PlayerAction = 'play' | 'pause' | 'toggle' | 'seek' | 'seekBy' | 'replay' | 'stop'
58
59type Player = { session: string; startedAt: number }
60
61const IMAGE_KEY = 'player'
62const STORE_PREFIX = 'player:'
63const MAX_CELLS = 255
64// A terminal cell is about twice as tall as it is wide.
65const CELL_ASPECT = 2.1
66const PLAYWRIGHT_CLI = ['npx', '--yes', '--package', '@playwright/cli', 'playwright-cli']
67
68const state = {
69  /** The session's working directory: where a Bash command runs unless it `cd`s. */
70  cwd: null as string | null,
71  /** Browser session name -> the recording following it, until it closes. */
72  live: new Map<string, string>(),
73  relay: null as Relay | null,
74  relayError: null as string | null,
75  players: new Map<string, Player>(),
76  frames: {} as Record<string, FrameInfo>,
77  /** What each player's Image was last drawn with, so a frame of the same size can be blitted. */
78  mounted: new Map<string, { columns: number; rows: number; width: number; height: number }>(),
79  offscreen: new Set<string>(),
80  watcher: 0,
81}
82
83const relayUrl = (path: string) => {
84  const relay = state.relay
85  if (!relay) throw new Error('the Replay live relay is not running')
86  const join = path.includes('?') ? '&' : '?'
87  return `http://127.0.0.1:${relay.port}${path}${join}token=${relay.token}`
88}
89
90async function relayJson<T>($: EngineInterface, path: string, body?: unknown): Promise<T> {
91  const response = await $.http.fetch(relayUrl(path), body === undefined
92    ? {}
93    : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
94  const parsed = response.text ? JSON.parse(response.text) : null
95  if (!response.ok) throw new Error(parsed?.error ?? `relay answered ${response.status}`)
96  return parsed as T
97}
98
99/** Starts the relay for the session's life and resolves once it is listening. */
100function startRelay($: EngineInterface): Promise<Relay> {
101  return new Promise((resolve, reject) => {
102    void (async () => {
103      let stdout = ''
104      let ready = false
105      try {
106        const child = $.process.spawn({ argv: ['node', `${$.plugin.root}/live/relay.mjs`] })
107        for await (const piece of child) {
108          if (piece.stream === 'stderr') {
109            $.ui.log(piece.text.trimEnd(), { to: 'debug' })
110            continue
111          }
112          if (ready) continue
113          stdout += piece.text
114          const line = stdout.split('\n').find(text => text.startsWith('{') && text.endsWith('}'))
115          if (!line) continue
116          ready = true
117          resolve(JSON.parse(line) as Relay)
118        }
119      } catch (err) {
120        if (!ready) reject(err)
121      }
122      state.relay = null
123      if (!ready) reject(new Error(`relay exited before listening: ${stdout.slice(-300)}`))
124    })()
125  })
126}
127
128/** Follows the relay's frame feed: blits new frames, redraws when a player changes shape. */
129async function watchFrames($: EngineInterface): Promise<void> {
130  const watcher = ++state.watcher
131  let version = -1
132  while (state.watcher === watcher && state.relay) {
133    let snapshot: { version: number; frames: Record<string, FrameInfo> }
134    try {
135      snapshot = await relayJson($, `/frames?version=${version}`)
136    } catch {
137      await $.clock.sleep(1000)
138      continue
139    }
140    version = snapshot.version
141    let redraw = false
142    for (const [id, fresh] of Object.entries(snapshot.frames)) {
143      const before = state.frames[id]
144      state.frames[id] = fresh
145      if (!state.players.has(id)) continue
146      const changedShape = !before
147        || before.status !== fresh.status
148        || before.mode !== fresh.mode
149        || before.seq !== fresh.seq
150        || before.totalMs !== fresh.totalMs
151        || before.export?.status !== fresh.export?.status
152        || Math.floor((before.export?.progress ?? 0) * 10) !== Math.floor((fresh.export?.progress ?? 0) * 10)
153        || before.recording?.status !== fresh.recording?.status
154        || before.recording?.lastError !== fresh.recording?.lastError
155        || before.recording?.url !== fresh.recording?.url
156      const mounted = state.mounted.get(id)
157      const fits = mounted && mounted.width === fresh.width && mounted.height === fresh.height
158      if (changedShape || !fits || before.generation === 0) {
159        redraw = true
160        continue
161      }
162      if (fresh.generation === before.generation || state.offscreen.has(id)) continue
163      const blit = await $.ui.blit({ requestId: id, key: IMAGE_KEY, source: imageSource(fresh) })
164      if (blit.deny) redraw = true
165    }
166    if (redraw) $.ui.invalidate('ui.render')
167  }
168}
169
170function imageSource(frame: { file: string; generation: number }) {
171  return { file: frame.file, format: 'png' as const, generation: frame.generation }
172}
173
174/** The CDP endpoint of an agent-browser session, which the relay joins to inject the tracer itself. */
175async function agentBrowserCdp($: EngineInterface, session: string): Promise<string> {
176  const res = await $.process.run(['agent-browser', '--session', session, 'get', 'cdp-url'], { timeoutMs: 30_000 })
177  const url = res.stdout.split('\n').map(line => line.trim()).find(line => /^wss?:\/\//.test(line))
178  if (res.exitCode !== 0 || !url) throw new Error(`agent-browser has no browser for session ${session}: ${(res.stderr || res.stdout).trim().slice(-200)}`)
179  return url
180}
181
182/** Injects the tracer into a session's browser and starts its live player. */
183async function attach($: EngineInterface, id: string, command: BrowserCommand, session: string): Promise<void> {
184  const cwd = command.cwd
185  if (command.tool === 'agent-browser') {
186    const cdp = await agentBrowserCdp($, session)
187    await relayJson($, '/recordings', { id, session, source: 'agent-browser', cwd, cdp })
188  } else {
189    const { injectPath } = await relayJson<{ injectPath: string }>($, '/recordings', { id, session, cwd })
190    // playwright-cli keeps a session per directory: inject from where the browser was opened.
191    const injected = await $.process.run(
192      [...PLAYWRIGHT_CLI, `--session=${session}`, 'run-code', '--filename', injectPath],
193      { timeoutMs: 90_000, ...(cwd ? { cwd } : {}) },
194    )
195    if (injected.exitCode !== 0 || !injected.stdout.includes('Replay live tracer attached')) {
196      await endSession($, session).catch(() => {})
197      throw new Error(`tracer injection failed${cwd ? ` in ${cwd}` : ''}: ${(injected.stderr || injected.stdout).trim().slice(-300)}`)
198    }
199  }
200  const player: Player = { session, startedAt: Date.now() }
201  state.players.set(id, player)
202  state.live.set(session, id)
203  await $.store.set(`${STORE_PREFIX}${id}`, player)
204  await relayJson($, `/recordings/${encodeURIComponent(id)}/player`, { mode: 'live' })
205  $.ui.status(`◉ Replay live: ${session}`)
206  $.ui.invalidate('ui.render')
207}
208
209async function endSession($: EngineInterface, session: string): Promise<void> {
210  state.live.delete(session)
211  await relayJson($, `/sessions/${encodeURIComponent(session)}/end`, {})
212  $.ui.status(undefined)
213}
214
215async function play($: EngineInterface, id: string, mode: PlayerAction, ms?: number): Promise<void> {
216  try {
217    await relayJson($, `/recordings/${encodeURIComponent(id)}/player`, { mode, ms })
218  } catch (err) {
219    $.ui.toast(`Replay player: ${err instanceof Error ? err.message : String(err)}`)
220  }
221}
222
223async function saveVideo($: EngineInterface, id: string): Promise<void> {
224  try {
225    await relayJson($, `/recordings/${encodeURIComponent(id)}/export`, {})
226    $.ui.toast('Rendering the recording to .replay/live/…')
227  } catch (err) {
228    $.ui.toast(`Save video: ${err instanceof Error ? err.message : String(err)}`)
229  }
230}
231
232async function uploadReplay($: EngineInterface, id: string): Promise<void> {
233  $.ui.toast('Uploading the Replay recording…')
234  try {
235    const links = await relayJson<ReplayLink[]>($, `/recordings/${encodeURIComponent(id)}/replay/upload`, {})
236    const done = links.filter(l => l.uploadStatus === 'uploaded')
237    $.ui.toast(done.length ? `Uploaded: ${done.map(l => l.url).join('  ')}` : 'Nothing was uploaded.')
238  } catch (err) {
239    const message = err instanceof Error ? err.message : String(err)
240    // Signed out, the relay opened Replay's browser sign-in: say what to do next.
241    $.ui.toast(/^Sign in/.test(message) ? message : `Upload failed: ${message}`)
242  }
243}
244
245/** Puts a prompt in the composer that hands the recording to Replay MCP. */
246async function debugWithClaude($: EngineInterface, links: ReplayLink[], rec: RecordingInfo | null | undefined): Promise<void> {
247  const ids = links.map(l => `${l.id} (${l.url})`).join(', ')
248  const page = rec?.url ? ` of ${rec.url}` : ''
249  const error = rec?.lastError ? ` The page reported: "${rec.lastError}".` : ''
250  await $.prompt.fill({
251    text: `Use the Replay MCP tools to debug the Replay recording ${ids} of the browser session${page}.${error} Start with RecordingOverview, then find the root cause.`,
252  })
253}
254
255function openUrl($: EngineInterface, url: string): void {
256  void $.process.run(['open', url]).catch(() => $.ui.toast(url))
257}
258
259function viewerUrl(id: string): string {
260  return relayUrl(`/player/${encodeURIComponent(id)}?mode=auto&chrome=1`)
261}
262
263function headline(id: string, frame: FrameInfo | undefined): string {
264  const rec = frame?.recording
265  const session = rec?.session ?? state.players.get(id)?.session ?? 'browser'
266  const where = rec?.url ? ` · ${rec.url}` : ''
267  const events = rec ? ` · ${rec.eventCount.toLocaleString()} events` : ''
268  if (rec?.status === 'live' && frame?.mode === 'live') return `◉ LIVE  ${session}${where}${events}`
269  return `■ Replay recording  ${session}${where}${events}`
270}
271
272function exportLine(job: ExportJob | null | undefined): string | null {
273  if (!job) return null
274  if (job.status === 'running') return `Rendering video… ${Math.round(job.progress * 100)}%`
275  if (job.status === 'failed') return `Save video failed: ${job.error ?? 'unknown error'}`
276  return `Saved ${job.files.join('  ')}`
277}
278
279export const register: Register = on => {
280  // Replay MCP tool results drawn as cards (hooks/mcp); screenshots go through the relay.
281  registerMcpCards(on, () => (state.relay ? { base: `http://127.0.0.1:${state.relay.port}`, token: state.relay.token } : null))
282
283  on('session.start', async ($, e, next) => {
284    state.cwd = e.cwd
285    const started = await next(e)
286    for (const key of await $.store.keys()) {
287      if (!key.startsWith(STORE_PREFIX)) continue
288      const player = (await $.store.get(key)) as Player | undefined
289      if (player) state.players.set(key.slice(STORE_PREFIX.length), player)
290    }
291    try {
292      state.relay = await startRelay($)
293      void watchFrames($)
294    } catch (err) {
295      state.relayError = err instanceof Error ? err.message : String(err)
296      $.ui.log(`replayio: live relay did not start: ${state.relayError}`)
297    }
298    await $.command.register({
299      name: 'replayio',
300      description: 'Replay recordings: list them, open the library, or save one as video',
301      argumentHint: '[library | save <session>]',
302    })
303    return started
304  })
305
306  on('command.run', { command: 'replayio' }, async ($, e) => {
307    if (!state.relay) return { text: `The Replay live relay is not running${state.relayError ? `: ${state.relayError}` : ''}.` }
308    const [verb, arg] = e.args.trim().split(/\s+/)
309    if (verb === 'library') {
310      openUrl($, relayUrl('/library'))
311      return { text: `Opened the Replay library: ${relayUrl('/library')}` }
312    }
313    const recordings = await relayJson<RecordingInfo[]>($, '/recordings?all=1')
314    if (verb === 'save') {
315      const rec = recordings.find(r => !arg || arg === 'latest' || r.session === arg || r.id === arg)
316      if (!rec) return { text: `No recording matches ${arg}.` }
317      await saveVideo($, rec.id)
318      return { text: `Saving \`${rec.session}\` as .mp4 + .rrweb.json under .replay/live/ (progress shows on its player).` }
319    }
320    if (recordings.length === 0) return { text: 'No Replay browser sessions recorded yet.' }
321    const clock = (ms: number) => `${Math.floor(ms / 60000)}:${String(Math.round(ms / 1000) % 60).padStart(2, '0')}`
322    const lines = recordings.slice(0, 20).map(r =>
323      `- ${r.status === 'live' ? '◉ live' : '■'} ${new Date(r.startedAt).toLocaleString()} \`${r.session}\` ${clock(r.durationMs)} ${r.url ?? ''} — [play](${viewerUrl(r.id)})`)
324    return { text: `Replay recordings (newest first; all of them in the library: /replayio library):\n${lines.join('\n')}` }
325  })
326
327  on('ui.message', async ($, e, next) => {
328    if (e.module !== './scrubber.tsx' || !state.players.has(e.requestId)) return next(e)
329    const data = e.data as { type?: string; ms?: number }
330    if (data.type === 'seek' || data.type === 'seekBy' || data.type === 'toggle') await play($, e.requestId, data.type, data.ms)
331    return next(e)
332  })
333
334  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
335    if (e.run_in_background) return next(e)
336    const parsed = parseBrowserCommands(e.command, state.cwd)
337    const ran = await next(e)
338    if (ran.deny !== undefined || ran.isError || !state.relay) return ran
339    // The text of a command can hide the CLI (a wrapper function, a variable): its output cannot.
340    const output = ran.text ?? ''
341    const commands = withOutput(parsed, output, endCwd(e.command, state.cwd))
342    try {
343      for (const [i, command] of commands.entries()) {
344        const name = command.session
345          ?? (command.kind === 'open' ? openedSessions(output)[0] : closedSessions(output)[0])
346          ?? 'default'
347        if (command.kind === 'close') {
348          for (const session of command.all ? [...state.live.keys()] : [name]) await endSession($, session)
349          continue
350        }
351        // Opened and closed within this one call: nothing left to watch.
352        if (commands.slice(i + 1).some(c => c.kind === 'close' && (c.all || (c.session ?? 'default') === name))) continue
353        // agent-browser `open` on a live session only navigates it.
354        if (command.tool === 'agent-browser' && state.live.has(name)) continue
355        await attach($, e.tool_use_id, command, name)
356      }
357    } catch (err) {
358      const message = err instanceof Error ? err.message : String(err)
359      $.ui.log(`replayio: live player: ${message}`)
360      $.ui.toast(`Replay live player could not attach: ${message}`)
361    }
362    return ran
363  })
364
365  // The engine folds runs of shell commands into one "Ran N shell commands" line, and an
366  // unfolded group draws each call as a ToolUse row with its output inline, so the player
367  // is drawn on the call's own row, and any group that holds a player is unfolded.
368  on('ui.render', { component: 'ToolGroup' }, ($, e, next) =>
369    !e.props.isExpanded && e.props.calls.some(c => c.tool_use_id && state.players.has(c.tool_use_id))
370      ? next({ ...e, props: { ...e.props, isExpanded: true } })
371      : next(e))
372
373  // A standalone call's result block would repeat the player.
374  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
375    if (!state.players.has(e.requestId) || e.props.isErrored) return next(e)
376    const { Box } = $.ui.resolve(e)
377    return <Box />
378  })
379
380  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
381    const id = e.requestId
382    const player = state.players.get(id)
383    if (!player || e.props.isErrored) return next(e)
384    if (e.props.onScreen === null) state.offscreen.add(id)
385    else state.offscreen.delete(id)
386
387    const frame = state.frames[id]
388    const ui = $.ui.resolve(e)
389    const { Box, Text, Button } = ui
390    const width = frame?.width ?? 1280
391    const height = frame?.height ?? 720
392    const columns = Math.max(20, Math.min(MAX_CELLS, (e.viewport?.columns ?? 100) - 6))
393    const rows = Math.max(6, Math.min(48, Math.round((columns * height) / width / CELL_ASPECT)))
394
395    // A saved last frame keeps the player in scrollback even before the relay draws again.
396    const savedFrame = state.relay ? `${state.relay.dataDir}/frames/${id.replace(/[^A-Za-z0-9_.-]/g, '_')}.png` : null
397    const source = frame && frame.generation > 0
398      ? imageSource(frame)
399      : savedFrame && (await $.fs.exists(savedFrame)) ? imageSource({ file: savedFrame, generation: 0 }) : null
400
401    const isLive = frame?.recording?.status === 'live' && frame.mode === 'live'
402    const isPlaying = frame?.status === 'playing' && frame.mode === 'replay'
403    const lastError = frame?.recording?.lastError
404    const saved = exportLine(frame?.export)
405    const picture = !source
406      ? <Text dimColor>{state.relay ? 'Waiting for the first frame…' : 'Replay live relay is not running.'}</Text>
407      : e.surface === 'terminal'
408        ? (() => {
409            state.mounted.set(id, { columns, rows, width, height })
410            const { Image } = $.ui.resolve(e)
411            return <Image key={IMAGE_KEY} source={source} columns={columns} rows={rows} alt={`Replay of ${player.session}`} />
412          })()
413        : <Text dimColor>Inline frames draw in the terminal; use Open in browser.</Text>
414
415    const scrubber = e.surface === 'terminal' && state.relay && frame
416      ? (() => {
417          const { Client } = $.ui.resolve(e)
418          return (
419            <Client
420              key="scrubber"
421              module="./scrubber.tsx"
422              width={columns}
423              height={1}
424              props={{ currentMs: frame.currentMs ?? 0, totalMs: frame.totalMs ?? 0, isPlaying, at: frame.at ?? Date.now(), isLive }}
425            />
426          )
427        })()
428      : null
429
430    return (
431      <Box flexDirection="column">
432        <Text dimColor wrap="truncate-end">$ {(e.props.input as { command?: string } | undefined)?.command?.split('\n')[0] ?? 'browser'}</Text>
433        <Box flexDirection="row" gap={1}>
434          {e.surface === 'terminal'
435            ? (() => {
436                const { Image } = $.ui.resolve(e)
437                return <Image key="logo" source={{ file: `${$.plugin.root}/assets/replay-logo.png`, format: 'png' }} columns={2} rows={1} alt="◆" />
438              })()
439            : null}
440          <Text bold={isLive}>{headline(id, frame)}</Text>
441        </Box>
442        {picture}
443        {scrubber}
444        {lastError ? <Text color="red">⚠ {lastError}</Text> : null}
445        {frame?.recording?.status === 'ended' && frame.recording.eventCount === 0
446          ? <Text color="yellow" wrap="wrap">No events were recorded: the page may not have loaded in this browser, or it blocked the tracer{lastError ? ` (${lastError})` : ''}. Open the page over http://localhost or a real URL and try again.</Text>
447          : null}
448        {state.relay ? (
449          <Box flexDirection="row" gap={1}>
450            {isLive
451              ? <Button key="replay" label="▶ Replay so far" onPress={() => play($, id, 'replay')} />
452              : (
453                <Box flexDirection="row" gap={1}>
454                  <Button key="start" label="⏮" onPress={() => play($, id, 'seek', 0)} />
455                  <Button key="back" label="−10s" onPress={() => play($, id, 'seekBy', -10_000)} />
456                  <Button key="toggle" variant="primary" label={isPlaying ? '❚❚ Pause' : '▶ Play'} onPress={() => play($, id, isPlaying ? 'pause' : 'play')} />
457                  <Button key="fwd" label="+10s" onPress={() => play($, id, 'seekBy', 10_000)} />
458                </Box>
459              )}
460            <Button key="save" label="⤓ Save video" onPress={() => saveVideo($, id)} />
461            <Button key="open" label="Open in browser" onPress={() => openUrl($, viewerUrl(id))} />
462          </Box>
463        ) : null}
464        {(() => {
465          const rec = frame?.recording
466          if (!state.relay || !rec || rec.status === 'live') return null
467          const links = rec.replay ?? []
468          if (links.length === 0) return <Text dimColor>No Replay recording linked (the browser was not Replay Chromium recording).</Text>
469          const isUploaded = links.every(l => l.uploadStatus === 'uploaded')
470          return (
471            <Box flexDirection="row" gap={1}>
472              <Text>
473                Replay recording {links.map(l => l.id.slice(0, 8)).join(', ')}
474                <Text dimColor> · {isUploaded ? 'uploaded' : links.some(l => l.recordingStatus === 'recording') ? 'still recording' : 'not uploaded'}</Text>
475              </Text>
476              {isUploaded
477                ? <Button key="replay-open" label="Open in Replay" onPress={() => openUrl($, links[0]?.url ?? '')} />
478                : <Button key="replay-upload" label="⇪ Upload" onPress={() => uploadReplay($, id)} />}
479              <Button key="replay-debug" variant="primary" label="Debug with Claude" onPress={() => debugWithClaude($, links, rec)} />
480            </Box>
481          )
482        })()}
483        {saved ? <Text dimColor={frame?.export?.status !== 'failed'} color={frame?.export?.status === 'failed' ? 'red' : undefined}>{saved}</Text> : null}
484      </Box>
485    )
486  })
487}
488
hooks/detect.ts 206 lines
1// Recognizes the Bash commands that open or close a browser session the live
2// players should follow, and the session names their output reports.
3//
4// Agents run these in many shapes: `playwright-cli …`, `npx -y @playwright/cli …`,
5// `"$PWCLI" …`, a wrapper such as `pw(){ npx -y @playwright/cli "$@"; }` then
6// `pw open …`, `agent-browser …`, with `cd`, `export` and pipes around them. The
7// command text is parsed for what it can tell, and the CLI's own output
8// (`Browser `x` opened`) covers the rest.
9
10export type BrowserTool = 'playwright-cli' | 'agent-browser'
11
12export type BrowserCommand = {
13  tool: BrowserTool
14  kind: 'open' | 'close'
15  /** The session named by a flag or environment variable; null when it is not given or is a shell variable. */
16  session: string | null
17  /**
18   * The directory the command ran in: playwright-cli keeps a session per
19   * directory, so the tracer must be injected from there. The base directory
20   * moved by any `cd` earlier in the command; null when a `cd` could not be
21   * followed (`~`, a variable) or no base was given.
22   */
23  cwd: string | null
24  /** `close --all`: every session. */
25  all?: true
26}
27
28const PLAYWRIGHT = /(^|[\s/"'])(playwright-cli|pwcli)(\.sh)?["']?$|@playwright\/cli["']?$|\$\{?PWCLI\}?["']?$|playwright_cli\.sh["']?$/i
29const AGENT = /(^|[\s/"'])agent-browser["']?$/
30const OPEN_SCRIPT = /browser-open\.js["']?$/
31const CLOSE_SCRIPT = /browser-close\.js["']?$/
32
33/** agent-browser flags that take a value, so the value is not mistaken for the verb. */
34const AGENT_VALUE_FLAGS = new Set([
35  '--session', '--executable-path', '--init-script', '--enable', '--args', '--profile', '--session-name', '--state',
36  '--headers', '--extension', '--user-agent', '--proxy', '--proxy-bypass', '--cdp', '--color-scheme', '--download-path',
37  '--provider', '-p', '--device', '--engine', '--model', '--config', '--screenshot-dir', '--screenshot-quality',
38  '--screenshot-format', '--max-output', '--allowed-domains', '--action-policy', '--confirm-actions',
39])
40const PLAYWRIGHT_VALUE_FLAGS = new Set(['-s', '--session', '--browser', '--config', '--profile'])
41
42/** Shell words of one simple command, quotes removed (no expansion). */
43function words(segment: string): string[] {
44  const out: string[] = []
45  const re = /"((?:\\.|[^"\\])*)"|'([^']*)'|(\S+)/g
46  for (let m = re.exec(segment); m; m = re.exec(segment)) {
47    out.push(m[1] ?? m[2] ?? m[3] ?? '')
48  }
49  return out
50}
51
52function literal(value: string | undefined): string | null {
53  // `--session="rt"` is one word with its quotes inside.
54  const unquoted = value?.replace(/["']/g, '')
55  if (!unquoted || unquoted.includes('$') || unquoted.includes('`')) return null
56  return unquoted
57}
58
59function sessionFlag(argv: string[]): string | null {
60  for (let i = 0; i < argv.length; i++) {
61    const arg = argv[i] ?? ''
62    const eq = /^(?:-s|--session)=(.*)$/.exec(arg)
63    if (eq) return literal(eq[1])
64    if (arg === '-s' || arg === '--session') return literal(argv[i + 1])
65  }
66  return null
67}
68
69/** The flag-free verb after the CLI word: `open`, `close`, `snapshot`... */
70function verb(argv: string[], valueFlags: Set<string>): string | null {
71  for (let i = 0; i < argv.length; i++) {
72    const arg = argv[i] ?? ''
73    if (valueFlags.has(arg)) {
74      i++
75      continue
76    }
77    if (arg.startsWith('-') || arg.startsWith('>') || /^\d?>/.test(arg)) continue
78    return arg
79  }
80  return null
81}
82
83/** Names the command defines as wrappers of a CLI: `pw(){ … @playwright/cli … }`, `alias`, `P="npx -y @playwright/cli"`. */
84function aliases(command: string): { playwright: Set<string>; agent: Set<string> } {
85  const playwright = new Set<string>()
86  const agent = new Set<string>()
87  const add = (name: string, body: string) => {
88    if (/playwright-cli|@playwright\/cli|pwcli/.test(body)) {
89      playwright.add(name)
90      playwright.add(`$${name}`)
91      playwright.add(`\${${name}}`)
92    } else if (/agent-browser/.test(body)) {
93      agent.add(name)
94      agent.add(`$${name}`)
95      agent.add(`\${${name}}`)
96    }
97  }
98  for (const m of command.matchAll(/([A-Za-z_][\w-]*)\s*\(\)\s*\{([^}]*)\}/g)) add(m[1]!, m[2]!)
99  for (const m of command.matchAll(/\balias\s+([\w-]+)=(["'])(.*?)\2/g)) add(m[1]!, m[3]!)
100  for (const m of command.matchAll(/(?:^|[\s;&|])([A-Za-z_]\w*)=(["'])([^"']*)\2/g)) add(m[1]!, m[3]!)
101  return { playwright, agent }
102}
103
104/** `dir` moved by `target`, as `cd` would, without the filesystem; null when it cannot be known. */
105export function joinDir(dir: string | null, target: string): string | null {
106  if (target.includes('$') || target.includes('`') || target.startsWith('~') || target === '-') return null
107  const parts = target.startsWith('/') ? [] : (dir ?? '').split('/').filter(Boolean)
108  if (!target.startsWith('/') && dir === null) return null
109  for (const part of target.split('/')) {
110    if (part === '' || part === '.') continue
111    if (part === '..') parts.pop()
112    else parts.push(part)
113  }
114  return `/${parts.join('/')}`
115}
116
117const SEGMENTS = /&&|\|\||[;\n|]/
118
119/** The directory a command leaves the shell in: `baseCwd` moved by every `cd` in it. */
120export function endCwd(command: string, baseCwd: string | null): string | null {
121  let cwd = baseCwd
122  for (const segment of command.split(SEGMENTS)) {
123    const argv = words(segment.trim())
124    const at = argv.findIndex(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w))
125    if (argv[at] === 'cd' && argv[at + 1] !== undefined) cwd = joinDir(cwd, argv[at + 1]!)
126  }
127  return cwd
128}
129
130export function parseBrowserCommands(command: string, baseCwd: string | null = null): BrowserCommand[] {
131  const found: BrowserCommand[] = []
132  const alias = aliases(command)
133  const env: Record<string, string> = {}
134  let cwd = baseCwd
135  for (const segment of command.split(SEGMENTS)) {
136    const argv = words(segment.trim())
137    for (const w of argv) {
138      const m = /^(?:export\s+)?([A-Za-z_]\w*)=(.*)$/.exec(w)
139      if (m) env[m[1]!] = m[2]!.replace(/["']/g, '')
140    }
141    // `cd dir`, possibly after VAR=value prefixes: later segments run there.
142    const cdAt = argv.findIndex(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) && w !== 'export')
143    if (argv[cdAt] === 'cd') {
144      cwd = argv[cdAt + 1] === undefined ? cwd : joinDir(cwd, argv[cdAt + 1]!)
145      continue
146    }
147    const toolOf = (w: string): BrowserTool | 'open-script' | 'close-script' | null =>
148      OPEN_SCRIPT.test(w) ? 'open-script'
149        : CLOSE_SCRIPT.test(w) ? 'close-script'
150          : PLAYWRIGHT.test(w) || alias.playwright.has(w) ? 'playwright-cli'
151            : AGENT.test(w) || alias.agent.has(w) ? 'agent-browser'
152              : null
153    let at = argv.findIndex(w => toolOf(w) !== null)
154    if (at < 0) continue
155    const kindOfTool = toolOf(argv[at]!)!
156    // `npx --package @playwright/cli playwright-cli …`: the real command is the last of the run.
157    while (argv[at + 1] !== undefined && toolOf(argv[at + 1]!) === kindOfTool) at++
158    const rest = argv.slice(at + 1)
159    if (kindOfTool === 'open-script') found.push({ tool: 'playwright-cli', kind: 'open', session: sessionFlag(rest), cwd })
160    else if (kindOfTool === 'close-script') found.push({ tool: 'playwright-cli', kind: 'close', session: sessionFlag(rest), cwd })
161    else if (kindOfTool === 'agent-browser') {
162      const v = verb(rest, AGENT_VALUE_FLAGS)
163      if (v === 'open' || v === 'close') {
164        const session = sessionFlag(rest) ?? literal(env.AGENT_BROWSER_SESSION) ?? 'default'
165        found.push({ tool: 'agent-browser', kind: v, session, cwd, ...(v === 'close' && rest.includes('--all') ? { all: true as const } : {}) })
166      }
167    } else {
168      const v = verb(rest, PLAYWRIGHT_VALUE_FLAGS)
169      if (v === 'open' || v === 'close') {
170        found.push({ tool: 'playwright-cli', kind: v, session: sessionFlag(rest) ?? literal(env.PLAYWRIGHT_CLI_SESSION), cwd })
171      }
172    }
173  }
174  return found
175}
176
177/** Sessions an output says were opened: playwright-cli's line, browser-open.js's JSON. */
178export function openedSessions(output: string): string[] {
179  const names = new Set<string>()
180  for (const m of output.matchAll(/Browser [`'"]([^`'"\s]+)[`'"] opened/g)) if (m[1]) names.add(m[1])
181  for (const m of output.matchAll(/"playwright_session"\s*:\s*"([^"]+)"/g)) if (m[1]) names.add(m[1])
182  return [...names]
183}
184
185/** Sessions an output says were closed. */
186export function closedSessions(output: string): string[] {
187  const names = new Set<string>()
188  for (const m of output.matchAll(/Browser [`'"]([^`'"\s]+)[`'"] closed/g)) if (m[1]) names.add(m[1])
189  return [...names]
190}
191
192/**
193 * The commands the text showed, plus any open or close only the output reveals
194 * (a wrapper function, an alias, a variable holding the CLI), closes first.
195 */
196export function withOutput(commands: BrowserCommand[], output: string, cwd: string | null): BrowserCommand[] {
197  const out = [...commands]
198  if (!commands.some(c => c.kind === 'open')) {
199    for (const session of openedSessions(output)) out.push({ tool: 'playwright-cli', kind: 'open', session, cwd })
200  }
201  if (!commands.some(c => c.kind === 'close')) {
202    for (const session of closedSessions(output).reverse()) out.unshift({ tool: 'playwright-cli', kind: 'close', session, cwd })
203  }
204  return out
205}
206
hooks/mcp/render.tsx 293 lines
1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4import { errorCard, truncate, type Block, type Card, type Status, type Tone, type ToolInput } from './card.ts'
5import { cardFor, replayToolName, resultText } from './cards.ts'
6
7// Draws each Replay MCP tool call as a card in place of its transcript row.
8// The card is built from the markdown the model read (no second call to the
9// server), so what the person sees is exactly the evidence Claude used.
10
11type On = Parameters<Register>[0]
12
13/** A local PNG the terminal can draw for a remote image (the relay converts JPEGs), with its size. */
14export type LocalImage = { file: string; width: number; height: number }
15
16/** The relay's address for image conversion (`http://127.0.0.1:<port>`, `token`), or null while it is down. */
17export type RelayAddress = () => { base: string; token: string } | null
18
19async function fetchImage($: EngineInterface, relay: { base: string; token: string }, url: string): Promise<LocalImage | null> {
20  try {
21    const res = await $.http.fetch(`${relay.base}/image?url=${encodeURIComponent(url)}&token=${relay.token}`)
22    return res.ok ? (JSON.parse(res.text) as LocalImage) : null
23  } catch {
24    return null
25  }
26}
27
28const ACCENT = '#a78bfa'
29const TONES: Record<Tone, string> = {
30  accent: ACCENT,
31  good: '#4ade80',
32  bad: '#f87171',
33  warn: '#fbbf24',
34  info: '#60a5fa',
35  muted: '#8b8b98',
36}
37const STATUS_TONE: Record<Status, Tone> = { complete: 'good', partial: 'warn', empty: 'muted', error: 'bad', running: 'info' }
38const ROW_HOVER = '#2b2540'
39
40function inputSummary(input: ToolInput): string {
41  return Object.entries(input)
42    .filter(([k, v]) => k !== 'recordingId' && v !== undefined && v !== '')
43    .map(([k, v]) => `${k}=${typeof v === 'string' ? truncate(v, 40) : JSON.stringify(v)}`)
44    .join(' ')
45}
46
47const state = {
48  /** tool_use_id -> ids of the sections the person opened */
49  open: new Map<string, Set<string>>(),
50  /** remote image url -> local PNG (null while resolving or when it failed) */
51  images: new Map<string, LocalImage | null>(),
52}
53
54export function registerMcpCards(on: On, relayAddress: RelayAddress): void {
55  // Runs of read-only calls fold into one count line; unfold the ones that hold Replay cards.
56  on('ui.render', { component: 'ToolGroup' }, ($, e, next) =>
57    e.props.calls.some(c => replayToolName(c.tool)) && !e.props.isExpanded
58      ? next({ ...e, props: { ...e.props, isExpanded: true } })
59      : next(e))
60
61  // The card replaces the call's row; its result block below would repeat it.
62  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
63    if (!replayToolName(e.props.tool)) return next(e)
64    const { Box } = $.ui.resolve(e)
65    return <Box />
66  })
67
68  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
69    const tool = replayToolName(e.props.tool)
70    if (!tool) return next(e)
71    const id = e.requestId
72    const input = (e.props.input ?? {}) as ToolInput
73    const width = Math.max(40, Math.min(140, (e.viewport?.columns ?? 100) - 4))
74    const card: Card = e.props.isRunning
75      ? { tool, title: tool, summary: inputSummary(input) || 'Querying the recording…', status: 'running', scope: 'recording-wide', facts: [], preview: [], sections: [], actions: [] }
76      : e.props.isInterrupted
77        ? { ...errorCard(tool, input, 'Interrupted'), summary: 'Interrupted' }
78        : cardFor(tool, input, resultText(e.props.output), e.props.isErrored)
79
80    const ui = $.ui.resolve(e)
81    const { Box, Text, Button, Markdown } = ui
82    const open = state.open.get(id) ?? new Set<string>()
83    const inner = width - 4
84
85    const toggle = (section: string) => {
86      const set = new Set(state.open.get(id) ?? [])
87      set.has(section) ? set.delete(section) : set.add(section)
88      state.open.set(id, set)
89      $.ui.invalidate('ui.render')
90    }
91
92    const tone = (t?: Tone) => (t ? TONES[t] : undefined)
93
94    const drawBlock = (block: Block, key: string) => {
95      switch (block.kind) {
96        case 'text':
97          return <Text key={key} color={tone(block.tone)} wrap="wrap">{block.text}</Text>
98        case 'markdown':
99          return <Markdown key={key} text={block.text} />
100        case 'facts':
101          return (
102            <Box key={key} flexDirection="column">
103              {block.items.map((f, i) => (
104                <Box key={`${key}-${i}`} flexDirection="row" gap={1}>
105                  <Text dimColor>{f.label}</Text>
106                  <Text color={tone(f.tone)} wrap="truncate-end">{f.value}</Text>
107                </Box>
108              ))}
109            </Box>
110          )
111        case 'list':
112          return (
113            <Box key={key} flexDirection="column">
114              {block.items.map((item, i) => (
115                <Box key={`${key}-${i}`} flexDirection="row" gap={1} hover={{ backgroundColor: ROW_HOVER }}>
116                  {item.badge ? <Text color={tone(item.tone)} bold>{item.badge}</Text> : <Text color={tone(item.tone) ?? ACCENT}>•</Text>}
117                  <Text color={item.badge ? undefined : tone(item.tone)} wrap="truncate-end">{item.text}</Text>
118                  {item.detail ? <Text dimColor wrap="truncate-end">{item.detail}</Text> : null}
119                </Box>
120              ))}
121            </Box>
122          )
123        case 'table': {
124          const cols = block.columns.length
125          const natural = block.columns.map((c, ci) => Math.max(c.length, ...block.rows.map(r => (r[ci] ?? '').length)))
126          // Shrink the widest columns until the row fits.
127          const widths = [...natural]
128          while (widths.reduce((a, b) => a + b, 0) + (cols - 1) * 2 > inner && Math.max(...widths) > 6) {
129            const wi = widths.indexOf(Math.max(...widths))
130            widths[wi] = widths[wi]! - 1
131          }
132          const cell = (s: string, ci: number) => {
133            const w = widths[ci]!
134            const t = truncate(s, w)
135            return block.numeric?.[ci] ? t.padStart(w) : t.padEnd(w)
136          }
137          return (
138            <Box key={key} flexDirection="column">
139              <Text dimColor bold>{block.columns.map(cell).join('  ')}</Text>
140              {block.rows.map((r, ri) => (
141                <Box key={`${key}-${ri}`} hover={{ backgroundColor: ROW_HOVER }}>
142                  <Text color={tone(block.tones?.[ri])}>{block.columns.map((_, ci) => cell(r[ci] ?? '', ci)).join('  ')}</Text>
143                </Box>
144              ))}
145            </Box>
146          )
147        }
148        case 'code': {
149          const nw = Math.max(2, ...block.lines.map(l => String(l.n ?? '').length))
150          const hw = Math.max(1, ...block.lines.map(l => (l.hits ?? '').length))
151          return (
152            <Box key={key} flexDirection="column">
153              {block.path ? <Text dimColor wrap="truncate-start">{block.path}</Text> : null}
154              {block.lines.map((l, li) => (
155                <Box key={`${key}-${li}`} flexDirection="row" hover={{ backgroundColor: ROW_HOVER }}>
156                  <Text color={l.hits && l.hits !== '0' ? TONES.info : TONES.muted}>{(l.hits ?? '').padStart(hw)} </Text>
157                  <Text dimColor>{String(l.n ?? '').padStart(nw)} </Text>
158                  <Text color={l.isMarked ? ACCENT : undefined} bold={l.isMarked} dimColor={l.isUnhit} wrap="truncate-end">
159                    {l.isMarked ? '▶ ' : '  '}{l.text}
160                  </Text>
161                </Box>
162              ))}
163            </Box>
164          )
165        }
166        case 'bars': {
167          const max = Math.max(...block.items.map(b => b.value), 1)
168          const lw = Math.min(28, Math.max(...block.items.map(b => b.label.length)))
169          const dw = Math.max(...block.items.map(b => b.display.length))
170          const bw = Math.max(6, inner - lw - dw - 3)
171          return (
172            <Box key={key} flexDirection="column">
173              {block.items.map((b, bi) => {
174                const len = Math.max(b.value > 0 ? 1 : 0, Math.round((b.value / max) * bw))
175                return (
176                  <Box key={`${key}-${bi}`} flexDirection="row" hover={{ backgroundColor: ROW_HOVER }}>
177                    <Text>{truncate(b.label, lw).padEnd(lw)} </Text>
178                    <Text color={tone(b.tone) ?? ACCENT}>{'█'.repeat(len)}</Text>
179                    <Text dimColor>{'·'.repeat(Math.max(0, bw - len))} {b.display.padStart(dw)}</Text>
180                  </Box>
181                )
182              })}
183            </Box>
184          )
185        }
186        case 'tree':
187          return (
188            <Box key={key} flexDirection="column">
189              {block.lines.map((l, li) => (
190                <Box key={`${key}-${li}`} flexDirection="row" hover={{ backgroundColor: ROW_HOVER }}>
191                  <Text>{'  '.repeat(l.depth)}</Text>
192                  <Text color={ACCENT}>{l.isOpen === undefined ? '  ' : l.isOpen ? '▼ ' : '▶ '}</Text>
193                  <Text wrap="truncate-end">{l.text}</Text>
194                  {l.detail ? <Text dimColor> {l.detail}</Text> : null}
195                </Box>
196              ))}
197            </Box>
198          )
199        case 'image': {
200          const image = state.images.get(block.url)
201          const relay = relayAddress()
202          if (image === undefined && relay) {
203            state.images.set(block.url, null)
204            void fetchImage($, relay, block.url).then(found => {
205              state.images.set(block.url, found)
206              if (found) $.ui.invalidate('ui.render')
207            })
208          }
209          if (image && e.surface === 'terminal') {
210            const { Image } = $.ui.resolve(e)
211            const columns = Math.min(inner, 90)
212            // A cell is about twice as tall as wide; keep the picture's aspect.
213            const rows = Math.max(4, Math.min(40, Math.round((columns * image.height) / image.width / 2.1)))
214            return (
215              <Box key={key} flexDirection="column">
216                <Image key={`${key}-img`} source={{ file: image.file, format: 'png' }} columns={columns} rows={rows} alt={block.caption ?? 'Screenshot'} />
217                {block.caption ? <Text dimColor>{block.caption}</Text> : null}
218              </Box>
219            )
220          }
221          return <Text key={key} dimColor>{block.caption ?? 'Screenshot'}: {block.url}</Text>
222        }
223      }
224    }
225
226    const runAction = async (i: number, surface: typeof e.surface) => {
227      const action = card.actions[i]
228      if (!action) return
229      if (action.kind === 'url') await $.process.run(['open', action.url]).catch(() => $.ui.toast(action.url))
230      else if (action.kind === 'prompt') await $.prompt.fill({ text: action.text })
231      else {
232        const copied = await $.ui.copy({ text: action.text, surface })
233        $.ui.toast(copied.isCopied ? 'Copied' : 'Could not copy here')
234      }
235    }
236
237    const statusTone = TONES[STATUS_TONE[card.status]]
238    // The Replay mark where the terminal can draw it; a plain diamond elsewhere.
239    const logo = e.surface === 'terminal'
240      ? (() => {
241          const { Image } = $.ui.resolve(e)
242          return <Image key="logo" source={{ file: `${$.plugin.root}/assets/replay-logo.png`, format: 'png' }} columns={2} rows={1} alt="◆" />
243        })()
244      : <Text color={ACCENT} bold>◆</Text>
245    return (
246      <Box flexDirection="column" borderStyle="round" borderColor={card.status === 'error' ? TONES.bad : ACCENT} paddingX={1} width={width}>
247        <Box flexDirection="row" justifyContent="space-between">
248          <Box flexDirection="row" gap={1} flexShrink={1}>
249            {logo}
250            <Text color={ACCENT} bold>{card.title}</Text>
251            <Text dimColor wrap="truncate-end">{card.summary}</Text>
252          </Box>
253          <Box flexDirection="row" gap={1} flexShrink={0}>
254            <Text color={statusTone}>{card.status === 'running' ? '◌ running' : card.status}</Text>
255            <Text dimColor>· {card.scope}</Text>
256          </Box>
257        </Box>
258        {card.facts.length ? (
259          <Box flexDirection="row" gap={2} flexWrap="wrap">
260            {card.facts.map((f, i) => (
261              <Box key={`fact-${i}`} flexDirection="row" gap={1}>
262                <Text bold color={tone(f.tone)}>{f.value}</Text>
263                <Text dimColor>{f.label}</Text>
264              </Box>
265            ))}
266          </Box>
267        ) : null}
268        {card.preview.map((b, i) => drawBlock(b, `preview-${i}`))}
269        {card.sections.length ? (
270          <Box flexDirection="row" gap={1} flexWrap="wrap">
271            {card.sections.map(s => (
272              <Button key={`sec-${s.id}`} plain label={`${open.has(s.id) ? '▾' : '▸'} ${s.title}`} onPress={() => toggle(s.id)} />
273            ))}
274          </Box>
275        ) : null}
276        {card.sections.filter(s => open.has(s.id)).map(s => (
277          <Box key={`open-${s.id}`} flexDirection="column" borderStyle="single" borderColor={TONES.muted} borderDimColor paddingX={1}>
278            <Text color={ACCENT}>{s.title}</Text>
279            {s.blocks.map((b, i) => drawBlock(b, `${s.id}-${i}`))}
280          </Box>
281        ))}
282        {card.actions.length ? (
283          <Box flexDirection="row" gap={1} flexWrap="wrap">
284            {card.actions.map((a, i) => (
285              <Button key={`act-${i}`} label={a.label} variant={i === 0 ? 'primary' : undefined} onPress={press => void runAction(i, press.surface)} />
286            ))}
287          </Box>
288        ) : null}
289      </Box>
290    )
291  })
292}
293
hooks/scrubber.tsx 88 lines
1/* @jsx h */
2import type { ClientKeyEvent, ClientPointerEvent, ClientSurface } from 'claude-code'
3
4// The player's seek bar, drawn in the terminal and driven by the pointer:
5// click or drag anywhere on the bar to seek, Space toggles play, ←/→ step 5s.
6// Between the plugin's redraws it advances the playhead on its own clock, so
7// steady playback costs no redraw of the transcript.
8
9export type ScrubberProps = {
10  currentMs: number
11  totalMs: number
12  /** Whether the playhead is moving: `currentMs` was true at `at`. */
13  isPlaying: boolean
14  at: number
15  isLive: boolean
16}
17
18type State = { tick: number; dragRatio: number | null }
19
20/** Each instance's newest props, for its timer, which outlives a render. */
21const latest = new WeakMap<object, ScrubberProps>()
22
23const clock = (ms: number) => {
24  const s = Math.max(0, Math.round(ms / 1000))
25  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
26}
27
28export default function Scrubber(raw: unknown, surface: ClientSurface<State>) {
29  const { Box, Text } = surface.elements
30  const props = raw as ScrubberProps
31  latest.set(surface, props)
32
33  const now = () => {
34    const moved = props.isPlaying ? Date.now() - props.at : 0
35    return Math.max(0, Math.min(props.totalMs, props.currentMs + moved))
36  }
37  const totalLabel = ` ${clock(props.totalMs)}`
38  const leftWidth = 6
39  const barWidth = Math.max(4, surface.columns - leftWidth - totalLabel.length)
40  const ratioAt = (x: number) => Math.max(0, Math.min(1, (x - leftWidth) / Math.max(1, barWidth - 1)))
41
42  if (surface.state === undefined) {
43    surface.setState({ tick: 0, dragRatio: null })
44    surface.every(250, () => {
45      const s = surface.state
46      if (s && latest.get(surface)?.isPlaying) surface.setState({ ...s, tick: s.tick + 1 })
47    })
48  }
49  surface.onPointer((event: ClientPointerEvent) => {
50    const s = surface.state
51    if (!s || props.isLive || props.totalMs <= 0) return
52    if (event.type === 'down') surface.setState({ ...s, dragRatio: ratioAt(event.x) })
53    else if (event.type === 'move' && s.dragRatio !== null) surface.setState({ ...s, dragRatio: ratioAt(event.x) })
54    else if (event.type === 'up' && s.dragRatio !== null) {
55      surface.post({ type: 'seek', ms: Math.round(ratioAt(event.x) * props.totalMs) })
56      surface.setState({ ...s, dragRatio: null })
57    } else if (event.type === 'leave' && s.dragRatio !== null) surface.setState({ ...s, dragRatio: null })
58  })
59  surface.onKey((event: ClientKeyEvent) => {
60    if (props.isLive) return
61    if (event.key === ' ' || event.key === 'space') surface.post({ type: 'toggle' })
62    else if (event.key === 'left') surface.post({ type: 'seekBy', ms: -5000 })
63    else if (event.key === 'right') surface.post({ type: 'seekBy', ms: 5000 })
64  })
65
66  if (props.isLive) {
67    return (
68      <Box flexDirection="row">
69        <Text color="red">● LIVE </Text>
70        <Text dimColor>scrub once the browser closes, or press ▶ Replay to scrub what is recorded so far</Text>
71      </Box>
72    )
73  }
74
75  const ratio = surface.state?.dragRatio ?? (props.totalMs > 0 ? now() / props.totalMs : 0)
76  const head = Math.round(ratio * (barWidth - 1))
77  const shown = surface.state?.dragRatio != null ? ratio * props.totalMs : now()
78  return (
79    <Box flexDirection="row">
80      <Text>{clock(shown).padStart(leftWidth - 1)} </Text>
81      <Text color="red">{'━'.repeat(head)}</Text>
82      <Text bold>●</Text>
83      <Text dimColor>{'─'.repeat(Math.max(0, barWidth - head - 1))}</Text>
84      <Text dimColor>{totalLabel}</Text>
85    </Box>
86  )
87}
88
hooks/mcp/card.ts 248 lines
1// The card every Replay MCP tool result is drawn as, and the markdown helpers
2// the per-tool parsers build cards with. Parsers are pure: (input, text) -> Card,
3// so they are tested against recorded tool outputs without a terminal.
4//
5// Anatomy (replay-mcpui PRD, component contract): a header (tool, one-line
6// summary, status and scope badges), 1-3 key facts, one primary evidence
7// preview, collapsible sections (consumed data, related events, raw payload)
8// and footer actions (open in Replay, ask Claude for the next step, copy).
9
10export type Tone = 'accent' | 'good' | 'bad' | 'warn' | 'info' | 'muted'
11
12export type Status = 'complete' | 'partial' | 'empty' | 'error' | 'running'
13
14export type Scope =
15  | 'recording-wide'
16  | 'point-scoped'
17  | 'source-scoped'
18  | 'component-scoped'
19  | 'request-scoped'
20  | 'profile-scoped'
21
22export type Fact = { label: string; value: string; tone?: Tone }
23
24export type CodeLine = { n?: number; hits?: string; text: string; isMarked?: boolean; isUnhit?: boolean }
25
26export type Block =
27  | { kind: 'text'; text: string; tone?: Tone }
28  | { kind: 'markdown'; text: string }
29  | { kind: 'facts'; items: Fact[] }
30  | { kind: 'list'; items: { text: string; detail?: string; tone?: Tone; badge?: string }[] }
31  | { kind: 'table'; columns: string[]; rows: string[][]; numeric?: boolean[]; tones?: (Tone | undefined)[] }
32  | { kind: 'code'; path?: string; lines: CodeLine[] }
33  | { kind: 'bars'; items: { label: string; value: number; display: string; tone?: Tone }[] }
34  | { kind: 'tree'; lines: { depth: number; text: string; detail?: string; isOpen?: boolean }[] }
35  | { kind: 'image'; url: string; caption?: string }
36
37export type Section = { id: string; title: string; blocks: Block[] }
38
39export type Action =
40  | { kind: 'prompt'; label: string; text: string }
41  | { kind: 'url'; label: string; url: string }
42  | { kind: 'copy'; label: string; text: string }
43
44export type Card = {
45  tool: string
46  title: string
47  summary: string
48  status: Status
49  scope: Scope
50  facts: Fact[]
51  preview: Block[]
52  sections: Section[]
53  actions: Action[]
54}
55
56export type ToolInput = Record<string, unknown> & { recordingId?: string }
57
58export type CardParser = (input: ToolInput, text: string) => Card
59
60// ------------------------------------------------------------------ markdown
61
62/** `**bold**`, `` `code` ``, `_em_` and link syntax removed. */
63export function plain(text: string): string {
64  return text
65    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
66    .replace(/\*\*([^*]+)\*\*/g, '$1')
67    .replace(/`([^`]+)`/g, '$1')
68    .replace(/(^|\s)_([^_]+)_(?=\s|$)/g, '$1$2')
69    .trim()
70}
71
72export type MdSection = { level: number; title: string; body: string }
73
74/** Splits on `#` headings; text before the first heading is a level-0 section. */
75export function sections(text: string): MdSection[] {
76  const out: MdSection[] = [{ level: 0, title: '', body: '' }]
77  let fence = false
78  for (const line of text.split('\n')) {
79    if (/^```/.test(line)) fence = !fence
80    const m = !fence && /^(#{1,6})\s+(.*)$/.exec(line)
81    if (m) out.push({ level: m[1]!.length, title: plain(m[2]!), body: '' })
82    else out[out.length - 1]!.body += `${line}\n`
83  }
84  return out.filter(s => s.title || s.body.trim())
85}
86
87/** The first section whose title matches. */
88export function section(text: string, title: RegExp): string | undefined {
89  return sections(text).find(s => title.test(s.title))?.body
90}
91
92/** `**Key:** value` and `- **Key:** value` lines, in order. */
93export function boldFacts(text: string): Fact[] {
94  const facts: Fact[] = []
95  for (const line of text.split('\n')) {
96    const m = /^\s*(?:[-*•]\s+)?\*\*([^*]+?):?\*\*:?\s*(.+)$/.exec(line)
97    if (m) facts.push({ label: m[1]!.replace(/:$/, '').trim(), value: plain(m[2]!) })
98  }
99  return facts
100}
101
102/** `Key: value` lines (no bold), as plain-text tools print them. */
103export function colonFacts(text: string, keys?: RegExp): Fact[] {
104  const facts: Fact[] = []
105  for (const line of text.split('\n')) {
106    const m = /^\s*([A-Z][A-Za-z ()/-]{1,40}):\s+(.+)$/.exec(line)
107    if (m && (!keys || keys.test(m[1]!))) facts.push({ label: m[1]!.trim(), value: plain(m[2]!) })
108  }
109  return facts
110}
111
112export function fact(facts: Fact[], label: RegExp): string | undefined {
113  return facts.find(f => label.test(f.label))?.value
114}
115
116export type MdTable = { columns: string[]; rows: string[][] }
117
118/** Every pipe table in the text. */
119export function tables(text: string): MdTable[] {
120  const out: MdTable[] = []
121  const lines = text.split('\n')
122  for (let i = 0; i < lines.length - 1; i++) {
123    const head = lines[i]!
124    const rule = lines[i + 1]!
125    // A header row, then a separator such as `|---|:--:|`, `------|------` or
126    // `| : | -----: |`: only pipes, colons, dashes and spaces, with a dash somewhere.
127    if (!head.includes('|') || !rule.includes('-') || !/^[\s|:-]+$/.test(rule)) continue
128    const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => plain(c.trim()))
129    const columns = cells(head)
130    const rows: string[][] = []
131    let j = i + 2
132    for (; j < lines.length && lines[j]!.includes('|') && lines[j]!.trim() !== ''; j++) rows.push(cells(lines[j]!))
133    out.push({ columns, rows })
134    i = j - 1
135  }
136  return out
137}
138
139/** `- item`, `• item`, `* item` and `1. item` lines. */
140export function bullets(text: string): string[] {
141  return text
142    .split('\n')
143    .map(l => /^\s*(?:[-*•]|\d+\.)\s+(.*)$/.exec(l)?.[1])
144    .filter((l): l is string => Boolean(l))
145    .map(plain)
146}
147
148/** The "Next Steps" / "Suggested Next Steps" bullets. */
149export function nextSteps(text: string): string[] {
150  const body = section(text, /next steps/i)
151  if (!body) return []
152  const lines: string[] = []
153  for (const raw of body.split('\n')) {
154    const m = /^\s*(?:[-*•]|\d+\.)\s+(.*)$/.exec(raw)
155    if (m) lines.push(plain(m[1]!))
156    else if (raw.trim() && lines.length) lines[lines.length - 1] += ` — ${plain(raw.trim())}`
157  }
158  return lines
159}
160
161export function points(text: string): string[] {
162  return [...new Set(text.match(/Point:\d+/g) ?? [])]
163}
164
165export const num = (s: string | undefined): number => {
166  const m = /-?[\d,]*\.?\d+/.exec(s ?? '')
167  return m ? Number(m[0].replace(/,/g, '')) : NaN
168}
169
170export function ms(value: number): string {
171  if (!Number.isFinite(value)) return '?'
172  if (value >= 60_000) return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`
173  if (value >= 1000) return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}s`
174  return `${Math.round(value)}ms`
175}
176
177export const truncate = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
178
179// ------------------------------------------------------------------- shared
180
181export const recordingUrl = (input: ToolInput) =>
182  input.recordingId ? `https://app.replay.io/recording/${input.recordingId}` : undefined
183
184/** Footer actions every card has: the Replay link, the next steps as prompts, and a copy of the output. */
185export function baseActions(input: ToolInput, text: string, extra: Action[] = []): Action[] {
186  const actions: Action[] = [...extra]
187  const url = recordingUrl(input)
188  if (url && !actions.some(a => a.kind === 'url')) actions.push({ kind: 'url', label: 'Open in Replay', url })
189  // A card with its own next action leaves out the generic "Next steps" prompts it would repeat.
190  for (const step of extra.some(a => a.kind === 'prompt') ? [] : nextSteps(text).slice(0, 2)) {
191    actions.push({ kind: 'prompt', label: `Next: ${truncate(step.replace(/^Use\s+/i, ''), 34)}`, text: `${step}${input.recordingId ? ` (recording ${input.recordingId})` : ''}` })
192  }
193  actions.push({ kind: 'copy', label: 'Copy output', text })
194  return actions
195}
196
197export function rawSection(text: string): Section {
198  const limit = 6000
199  return { id: 'raw', title: 'Raw output', blocks: [{ kind: 'markdown', text: text.length > limit ? `${text.slice(0, limit)}\n\n… ${text.length - limit} more characters` : text }] }
200}
201
202/** A card for an error the tool reported, or a recording that lacks what it inspects. */
203export function errorCard(tool: string, input: ToolInput, text: string, scope: Scope = 'recording-wide'): Card {
204  const message = plain(text.replace(/^\[[A-Za-z]+\]\s*/, '')) || 'The tool returned an error.'
205  const isNotFound = /not found in this recording|requires a recording/i.test(text)
206  const isDenied = /access denied|permission/i.test(text)
207  return {
208    tool,
209    title: tool,
210    summary: isNotFound ? 'Not used in this recording' : isDenied ? 'No access to this recording' : 'The tool reported an error',
211    status: isNotFound ? 'empty' : 'error',
212    scope,
213    facts: [],
214    preview: [{ kind: 'text', text: message, tone: isNotFound ? 'muted' : 'bad' }],
215    sections: [],
216    actions: isDenied
217      ? [{ kind: 'prompt', label: 'Sign in to Replay', text: 'The Replay MCP tool says access was denied. Start `replayio login` in the background and wait for me to confirm I have signed in.' }]
218      : baseActions(input, text),
219  }
220}
221
222/** The fallback card: headline facts, the first section as the preview, the rest collapsed. */
223export function genericCard(tool: string, input: ToolInput, text: string, scope: Scope = 'recording-wide'): Card {
224  const parts = sections(text).filter(s => !/next steps/i.test(s.title))
225  const facts = boldFacts(text).slice(0, 3)
226  const first = parts[0]
227  const firstTable = first ? tables(first.body)[0] : undefined
228  const preview: Block[] = first
229    ? firstTable
230      ? [{ kind: 'table', columns: firstTable.columns, rows: firstTable.rows.slice(0, 8) }]
231      : [{ kind: 'markdown', text: truncate(first.body.trim(), 900) }]
232    : []
233  return {
234    tool,
235    title: tool,
236    summary: first?.title || truncate(plain(text.split('\n').find(l => l.trim()) ?? ''), 80),
237    status: text.trim() ? 'complete' : 'empty',
238    scope,
239    facts,
240    preview,
241    sections: [
242      ...parts.slice(1, 6).map((s, i) => ({ id: `s${i}`, title: s.title || 'Details', blocks: [{ kind: 'markdown' as const, text: truncate(s.body.trim(), 3000) }] })),
243      rawSection(text),
244    ],
245    actions: baseActions(input, text),
246  }
247}
248
hooks/mcp/cards.ts 39 lines
1// Which transcript rows are Replay MCP calls, and the card each one's result
2// becomes. Kept free of JSX so tests run it under plain Node.
3
4import { errorCard, genericCard, type Card, type ToolInput } from './card.ts'
5import { PARSERS } from './tools/index.ts'
6
7/** `mcp__replay__X` (a server added by hand) or `mcp__plugin_replayio_replay__X` (this plugin's). */
8export function replayToolName(tool: string): string | null {
9  const m = /^mcp__(?:.+_)?replay__([A-Z][A-Za-z]+)$/.exec(tool)
10  return m ? m[1]! : null
11}
12
13/** The text of an MCP result as the transcript stores it: a string, content blocks, or `{ content }`. */
14export function resultText(output: unknown): string {
15  if (typeof output === 'string') return output
16  if (Array.isArray(output)) {
17    return output
18      .map(b => (b && typeof b === 'object' && 'text' in b && typeof b.text === 'string' ? b.text : ''))
19      .filter(Boolean)
20      .join('\n\n')
21  }
22  if (output && typeof output === 'object') {
23    const o = output as Record<string, unknown>
24    if ('content' in o) return resultText(o.content)
25    if (typeof o.text === 'string') return o.text
26  }
27  return ''
28}
29
30export function cardFor(tool: string, input: ToolInput, text: string, isErrored: boolean): Card {
31  if (isErrored || /^\[(Error|InputValidation)\]/.test(text.trim())) return errorCard(tool, input, text)
32  const parse = PARSERS[tool]
33  try {
34    return parse ? parse(input, text) : genericCard(tool, input, text)
35  } catch {
36    return genericCard(tool, input, text)
37  }
38}
39
hooks/mcp/tools/index.ts 12 lines
1import type { CardParser } from '../card.ts'
2import { CODE_PARSERS } from './code.ts'
3import { OVERVIEW_PARSERS } from './overview.ts'
4import { REACT_PARSERS } from './react.ts'
5
6/** Replay MCP tool name -> its card. A tool missing here is drawn by `genericCard`. */
7export const PARSERS: Record<string, CardParser> = {
8  ...OVERVIEW_PARSERS,
9  ...CODE_PARSERS,
10  ...REACT_PARSERS,
11}
12
hooks/mcp/tools/code.ts 564 lines
1// Cards for the point-scoped and source-scoped Replay MCP tools: what ran at a
2// moment (DescribePoint, Evaluate, GetStack, GetPointComponent, GetPointLink,
3// InspectElement, Logpoint) and the code itself (ListSources, ReadSource,
4// SearchSources, ExecutionDelay).
5
6import {
7  baseActions,
8  genericCard,
9  ms,
10  num,
11  plain,
12  rawSection,
13  truncate,
14  type Block,
15  type Card,
16  type CardParser,
17  type CodeLine,
18  type Fact,
19  type Tone,
20  type ToolInput,
21} from '../card.ts'
22
23// ------------------------------------------------------------------ helpers
24
25const isLibrary = (location: string) => /node_modules|\.vite\/deps|\/@vite\/|\/@react-refresh|record-replay-/.test(location)
26
27/** The path relative to its origin, so `http://127.0.0.1:4173/src/a.tsx` reads `/src/a.tsx`. */
28function shortPath(url: string): string {
29  const m = /^[a-z]+:\/\/[^/]+(\/.*)$/i.exec(url.trim())
30  const path = m ? m[1]! : url.trim()
31  return path.replace(/^\/@fs\/.*?\/(packages|src|node_modules)\//, '/$1/')
32}
33
34const fileName = (url: string) => url.split(/[/?#]/).filter(Boolean).pop() ?? url
35
36/** Rows of a `hits | L | code` table: `*` marks the requested line, `/*<HIT>*\/` a breakpoint that ran. */
37function codeLines(text: string): CodeLine[] {
38  const lines: CodeLine[] = []
39  for (const raw of text.split('\n')) {
40    const m = /^\s*(\d*)\s*\|\s*(\*?)(\d+)\s*\|(.*)$/.exec(raw)
41    if (!m) continue
42    const hits = m[1] ?? ''
43    lines.push({
44      n: Number(m[3]),
45      hits,
46      text: (m[4] ?? '').replace(/^ /, '').replace(/\/\*<HIT>\*\//g, '').replace(/\s+$/, ''),
47      isMarked: m[2] === '*',
48      isUnhit: hits === '0',
49    })
50  }
51  return lines
52}
53
54/** Keeps the marked lines and up to `around` lines on either side, at most `max` lines. */
55function aroundMarked(lines: CodeLine[], around: number, max: number): CodeLine[] {
56  const marked = lines.map((l, i) => (l.isMarked ? i : -1)).filter(i => i >= 0)
57  if (marked.length === 0) return lines.slice(0, max)
58  const keep = new Set<number>()
59  for (const i of marked) for (let j = i - around; j <= i + around; j++) if (j >= 0 && j < lines.length) keep.add(j)
60  return [...keep].sort((a, b) => a - b).slice(0, max).map(i => lines[i]!)
61}
62
63/** `Point:N` as given; a raw execution point (a long number) shortened to its tail. */
64const pointLabel = (input: ToolInput) => {
65  if (typeof input.point !== 'string') return undefined
66  return /^\d{12,}$/.test(input.point) ? `point …${input.point.slice(-6)}` : input.point
67}
68
69function card(tool: string, partial: Omit<Card, 'tool' | 'title'> & { title?: string }): Card {
70  return { tool, title: partial.title ?? tool, ...partial }
71}
72
73// ------------------------------------------------------------------- parsers
74
75/** DescribePoint: the function around the point, which statements ran, and its variables. */
76const describePoint: CardParser = (input, text) => {
77  const time = /Timestamp:\s*([\d.]+)ms/.exec(text)
78  const origin = /^Source:\s*(.+)$/m.exec(text)
79  const fence = /```[a-z]*\n([\s\S]*?)```/.exec(text)
80  const vars = [...text.matchAll(/Variable (\S+) has contents ([\s\S]*?)(?=\n\s*\n|\nVariable |\nSource:|$)/g)].map(m => ({
81    name: m[1]!,
82    value: m[2]!.trim(),
83  }))
84  if (!fence && vars.length === 0) return genericCard('DescribePoint', input, text, 'point-scoped')
85  // The code annotates each statement with a comment line: keep the code, mark what did not run.
86  const lines: CodeLine[] = []
87  let ran: boolean | undefined
88  for (const line of (fence?.[1] ?? '').split('\n')) {
89    const note = /^\s*\/\/ This statement was (not )?executed/.exec(line)
90    if (note) {
91      ran = !note[1]
92      continue
93    }
94    if (!line.trim()) continue
95    lines.push({ text: line, isUnhit: ran === false })
96    ran = undefined
97  }
98  const fn = /^\s*(?:async\s+)?function\s*\*?\s*([\w$]*)/.exec(lines[0]?.text ?? '')?.[1]
99  const facts: Fact[] = []
100  if (time) facts.push({ label: 'at', value: ms(Number(time[1])), tone: 'info' })
101  if (fn) facts.push({ label: 'function', value: fn, tone: 'accent' })
102  if (vars.length) facts.push({ label: vars.length === 1 ? 'variable' : 'variables', value: String(vars.length) })
103  const varList: Block = { kind: 'list', items: vars.map(v => ({ text: v.name, detail: truncate(v.value.replace(/\s+/g, ' '), 90) })) }
104  return card('DescribePoint', {
105    summary: `${fn ? `${fn}()` : 'Code'} at ${pointLabel(input) ?? 'the point'}${origin ? `, caused by ${plain(origin[1]!)}` : ''}`,
106    status: 'complete',
107    scope: 'point-scoped',
108    facts,
109    preview: [...(lines.length ? [{ kind: 'code' as const, lines: lines.slice(0, 12) }] : []), ...(vars.length ? [varList] : [])],
110    sections: [
111      ...(lines.length > 12 ? [{ id: 'code', title: 'Whole function', blocks: [{ kind: 'code' as const, lines }] }] : []),
112      {
113        id: 'consumed',
114        title: 'Consumed data',
115        blocks: [
116          {
117            kind: 'facts',
118            items: [
119              { label: 'point', value: pointLabel(input) ?? '?' },
120              ...(time ? [{ label: 'timestamp', value: `${time[1]}ms` }] : []),
121              ...(origin ? [{ label: 'origin event', value: plain(origin[1]!) }] : []),
122            ],
123          },
124          ...vars.map(v => ({ kind: 'text' as const, text: `${v.name} = ${v.value}` })),
125        ],
126      },
127      rawSection(text),
128    ],
129    actions: baseActions(input, text, [
130      { kind: 'prompt', label: 'Get the call stack', text: `Use the Replay MCP GetStack tool at ${pointLabel(input) ?? 'this point'} (recording ${input.recordingId}).` },
131      { kind: 'prompt', label: 'Trace the cause', text: `Use the Replay MCP DescribePoint tool with dependencyChain=true at ${pointLabel(input) ?? 'this point'} (recording ${input.recordingId}) to trace what caused it.` },
132    ]),
133  })
134}
135
136/** Evaluate: the expression and the value it had at the point. */
137const evaluate: CardParser = (input, text) => {
138  const expression = typeof input.expression === 'string' ? input.expression : '(expression)'
139  const value = text.trim()
140  const isError = /^(Error|Exception|Uncaught|ReferenceError|TypeError|SyntaxError)\b/.test(value)
141  const isMultiline = value.includes('\n') || value.length > 120
142  return card('Evaluate', {
143    summary: `${truncate(expression, 60)} at ${pointLabel(input) ?? 'the point'}`,
144    status: value ? (isError ? 'error' : 'complete') : 'empty',
145    scope: 'point-scoped',
146    facts: [{ label: 'type', value: valueType(value), tone: isError ? 'bad' : 'accent' }],
147    preview: [
148      { kind: 'text', text: `› ${expression}`, tone: 'muted' },
149      isMultiline
150        ? { kind: 'markdown', text: `\`\`\`\n${truncate(value, 1500)}\n\`\`\`` }
151        : { kind: 'text', text: value || '(no value)', tone: isError ? 'bad' : 'good' },
152    ],
153    sections: [
154      { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: [{ label: 'point', value: pointLabel(input) ?? '?' }, { label: 'expression', value: expression }, { label: 'scope', value: String(input.scope ?? 'frame') }] }] },
155      rawSection(text),
156    ],
157    actions: baseActions(input, text, [
158      { kind: 'prompt', label: 'Evaluate another', text: `Use the Replay MCP Evaluate tool at ${pointLabel(input) ?? 'the same point'} (recording ${input.recordingId}) with the expression: ` },
159    ]),
160  })
161}
162
163function valueType(value: string): string {
164  if (!value) return 'empty'
165  if (/^".*"$/s.test(value)) return 'string'
166  if (/^-?\d+(\.\d+)?$/.test(value)) return 'number'
167  if (/^(true|false)$/.test(value)) return 'boolean'
168  if (/^(null|undefined)$/.test(value)) return value
169  if (/^\[/.test(value)) return 'array'
170  if (/^\{/.test(value)) return 'object'
171  if (/^(Error|\w+Error)\b/.test(value)) return 'error'
172  return 'value'
173}
174
175/** GetStack: the frames from the point outwards, app code highlighted over library code. */
176const getStack: CardParser = (input, text) => {
177  const frames = [...text.matchAll(/^\s*(\d+)\.\s+(.+)\n\s+at\s+(.+)(?:\n\s+point:\s*(\S+))?/gm)].map(m => ({
178    index: Number(m[1]),
179    fn: m[2]!.trim(),
180    location: m[3]!.trim(),
181    point: m[4],
182  }))
183  if (frames.length === 0) return genericCard('GetStack', input, text, 'point-scoped')
184  const appFrames = frames.filter(f => !isLibrary(f.location))
185  const top = frames[0]!
186  const items = frames.map(f => ({
187    text: `${f.index}. ${f.fn}`,
188    detail: `${shortPath(f.location)}${f.point ? `  ${f.point}` : ''}`,
189    tone: (isLibrary(f.location) ? 'muted' : undefined) as Tone | undefined,
190  }))
191  return card('GetStack', {
192    summary: `${frames.length} frame${frames.length === 1 ? '' : 's'} at ${pointLabel(input) ?? 'the point'}, innermost ${top.fn}`,
193    status: 'complete',
194    scope: 'point-scoped',
195    facts: [
196      { label: 'frames', value: String(frames.length) },
197      { label: 'app frames', value: String(appFrames.length), tone: 'accent' },
198      { label: 'innermost', value: fileName(top.location) },
199    ],
200    preview: [{ kind: 'list', items: items.slice(0, 12) }],
201    sections: [
202      ...(items.length > 12 ? [{ id: 'all', title: `All ${items.length} frames`, blocks: [{ kind: 'list' as const, items }] }] : []),
203      rawSection(text),
204    ],
205    actions: baseActions(input, text, [
206      { kind: 'prompt', label: `Describe ${top.fn}`, text: `Use the Replay MCP DescribePoint tool at ${top.point ?? pointLabel(input) ?? 'the innermost frame'} (recording ${input.recordingId}).` },
207      ...(appFrames[0] && appFrames[0] !== top
208        ? [{ kind: 'prompt' as const, label: `Describe ${appFrames[0].fn}`, text: `Use the Replay MCP DescribePoint tool at ${appFrames[0].point ?? 'its point'} (recording ${input.recordingId}).` }]
209        : []),
210    ]),
211  })
212}
213
214/** GetPointComponent: the React component handle rendering at the point. */
215const getPointComponent: CardParser = (input, text) => {
216  const handle = /Component:\d+/.exec(text)?.[0]
217  const name = /component\s+([A-Z][\w$.]*)/.exec(text)?.[1]
218  if (!handle && !text.trim()) {
219    return card('GetPointComponent', {
220      summary: `No React component was rendering at ${pointLabel(input) ?? 'the point'}`,
221      status: 'empty',
222      scope: 'component-scoped',
223      facts: [],
224      preview: [{ kind: 'text', text: 'The point is outside a React render.', tone: 'muted' }],
225      sections: [],
226      actions: baseActions(input, text),
227    })
228  }
229  return card('GetPointComponent', {
230    summary: `${name ?? handle ?? 'A component'} was rendering at ${pointLabel(input) ?? 'the point'}`,
231    status: handle || name ? 'complete' : 'partial',
232    scope: 'component-scoped',
233    facts: [...(handle ? [{ label: 'component', value: handle, tone: 'accent' as Tone }] : []), ...(name ? [{ label: 'name', value: name }] : [])],
234    preview: [{ kind: 'text', text: truncate(plain(text.trim()), 300) }],
235    sections: [rawSection(text)],
236    actions: baseActions(input, text, [
237      {
238        kind: 'prompt',
239        label: 'Show in component tree',
240        text: `Use the Replay MCP ReactComponentTree tool in mode 'subtree' for ${handle ?? name ?? 'that component'} (recording ${input.recordingId}).`,
241      },
242    ]),
243  })
244}
245
246/** GetPointLink: the app.replay.io link to the point, opened or copied. */
247const getPointLink: CardParser = (input, text) => {
248  const url = /https?:\/\/\S+/.exec(text)?.[0]
249  if (!url) return genericCard('GetPointLink', input, text, 'point-scoped')
250  const time = num(/[?&]time=([\d.]+)/.exec(url)?.[1])
251  return card('GetPointLink', {
252    summary: `Replay link to ${pointLabel(input) ?? 'the point'}`,
253    status: 'complete',
254    scope: 'point-scoped',
255    facts: Number.isFinite(time) ? [{ label: 'at', value: ms(time), tone: 'info' }] : [],
256    preview: [{ kind: 'text', text: truncate(url, 160), tone: 'info' }],
257    sections: [],
258    actions: [
259      { kind: 'url', label: 'Open point in Replay', url },
260      { kind: 'copy', label: 'Copy link', text: url },
261    ],
262  })
263}
264
265/** InspectElement: the element, its size and component, then its ancestors' boxes. */
266const inspectElement: CardParser = (input, text) => {
267  const [self = '', ...parents] = text.split(/\n### /)
268  const tag = /Element:\d+ is `(<[^`]*?>)/.exec(self)?.[1]
269  if (!tag) return genericCard('InspectElement', input, text, 'point-scoped')
270  const shortTag = tag.replace(/\sclass="[^"]*"/, ' class="…"').replace(/\s(?:data-[\w-]+|id)="[^"]*"/g, '')
271  const size = /has (\d+)px width and (\d+)px height/.exec(self)
272  const component = /rendered by React component (\S+) at (Point:\d+)/.exec(self)
273  const code = /Relevant code:\s*(\S+) line (\d+)(?: column (\d+))?/.exec(self)
274  const obstructed = /No elements are obstructing/.test(self) ? 'clear' : /obstruct/i.test(self) ? 'obstructed' : undefined
275  const facts: Fact[] = []
276  if (size) facts.push({ label: 'size', value: `${size[1]}×${size[2]}px` })
277  if (component) facts.push({ label: 'component', value: component[1]!, tone: 'accent' })
278  if (obstructed) facts.push({ label: 'center', value: obstructed, tone: obstructed === 'clear' ? 'good' : 'warn' })
279  const boxLines = self.split('\n').filter(l => /^(Margin|Padding|Overflow|The (width|height) is set)|^- /.test(l.trim())).map(l => l.trim())
280  const ancestors = parents.map(p => {
281    const t = /Element:\d+ is `(<[^\s>]+)/.exec(p)?.[1] ?? '<?'
282    const c = /React component (\S+)/.exec(p)?.[1]
283    const s = /has (\d+)px width and (\d+)px height/.exec(p)
284    return { text: `${t}>`, detail: [c, s ? `${s[1]}×${s[2]}` : ''].filter(Boolean).join('  ') }
285  })
286  return card('InspectElement', {
287    summary: `${/^<([\w-]+)/.exec(tag)?.[1] ?? 'element'}${component ? ` rendered by ${component[1]}` : ''}`,
288    status: 'complete',
289    scope: 'point-scoped',
290    facts: facts.slice(0, 3),
291    preview: [
292      { kind: 'text', text: truncate(shortTag, 200), tone: 'info' },
293      ...(code ? [{ kind: 'text' as const, text: `${shortPath(code[1]!)}:${code[2]}${code[3] ? `:${code[3]}` : ''}`, tone: 'muted' as Tone }] : []),
294    ],
295    sections: [
296      { id: 'box', title: 'Box model', blocks: [{ kind: 'list', items: boxLines.map(l => ({ text: l.replace(/^- /, '') })) }] },
297      ...(ancestors.length ? [{ id: 'ancestors', title: `Ancestors (${ancestors.length})`, blocks: [{ kind: 'list' as const, items: ancestors }] }] : []),
298      { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'text', text: tag }] },
299      rawSection(text),
300    ],
301    actions: baseActions(input, text, [
302      ...(component ? [{ kind: 'prompt' as const, label: `Describe ${component[1]}`, text: `Use the Replay MCP DescribePoint tool at ${component[2]} (recording ${input.recordingId}).` }] : []),
303      ...(code ? [{ kind: 'prompt' as const, label: 'Read its source', text: `Use the Replay MCP ReadSource tool on ${code[1]} line ${code[2]} (recording ${input.recordingId}).` }] : []),
304    ]),
305  })
306}
307
308/** Logpoint: every hit of the line with the expression's value (or error) there. */
309const logpoint: CardParser = (input, text) => {
310  const total = num(/Found (\d+) total hits/.exec(text)?.[1])
311  const where = /hits at (\S+)/.exec(text)?.[1]
312  const hits = [...text.matchAll(/Hit (\d+) at point (\S+) @ ([\d.]+)ms:\n(?:\s+Expression:.*\n)?\s+(Error|Result|Value):\s*(.*)/g)].map(m => ({
313    n: Number(m[1]),
314    point: m[2]!,
315    time: Number(m[3]),
316    isError: m[4] === 'Error',
317    value: m[5]!.trim(),
318  }))
319  const expression = typeof input.expression === 'string' ? input.expression : '(expression)'
320  const path = typeof input.path === 'string' ? input.path : where?.replace(/:\d+$/, '') ?? ''
321  if (hits.length === 0 && !Number.isFinite(total)) {
322    if (/no hits|0 total hits|never executed/i.test(text) || !text.trim()) {
323      return card('Logpoint', {
324        summary: `${fileName(path)}:${input.line ?? '?'} never ran`,
325        status: 'empty',
326        scope: 'source-scoped',
327        facts: [{ label: 'hits', value: '0', tone: 'muted' }],
328        preview: [{ kind: 'text', text: text.trim() || 'The line never executed in this recording.', tone: 'muted' }],
329        sections: [rawSection(text)],
330        actions: baseActions(input, text),
331      })
332    }
333    return genericCard('Logpoint', input, text, 'source-scoped')
334  }
335  const errors = hits.filter(h => h.isError).length
336  const allErrored = hits.length > 0 && errors === hits.length
337  const items = hits.map(h => ({
338    badge: `#${h.n}`,
339    text: truncate(h.value, 100),
340    detail: `${h.point} @ ${ms(h.time)}`,
341    tone: (h.isError ? 'bad' : 'info') as Tone,
342  }))
343  return card('Logpoint', {
344    summary: `${expression} at ${fileName(path)}:${input.line ?? '?'}`,
345    status: allErrored ? 'error' : errors ? 'partial' : 'complete',
346    scope: 'source-scoped',
347    facts: [
348      { label: 'hits', value: String(Number.isFinite(total) ? total : hits.length), tone: 'info' },
349      ...(errors ? [{ label: 'errored', value: String(errors), tone: 'bad' as Tone }] : []),
350      ...(hits.length ? [{ label: 'span', value: `${ms(hits[0]!.time)} – ${ms(hits[hits.length - 1]!.time)}` }] : []),
351    ],
352    preview: [{ kind: 'text', text: `› ${expression}`, tone: 'muted' }, { kind: 'list', items: items.slice(0, 10) }],
353    sections: [
354      ...(items.length > 10 ? [{ id: 'all', title: `All ${items.length} hits`, blocks: [{ kind: 'list' as const, items }] }] : []),
355      {
356        id: 'consumed',
357        title: 'Consumed data',
358        blocks: [{ kind: 'facts', items: [{ label: 'source', value: shortPath(path) }, { label: 'line', value: String(input.line ?? '?') }, { label: 'expression', value: expression }, ...(input.beginPoint ? [{ label: 'from', value: String(input.beginPoint) }] : []), ...(input.endPoint ? [{ label: 'to', value: String(input.endPoint) }] : [])] }],
359      },
360      rawSection(text),
361    ],
362    actions: baseActions(input, text, [
363      ...(allErrored
364        ? [{ kind: 'prompt' as const, label: 'Fix the expression', text: `Every hit of the Replay logpoint "${expression}" at ${path}:${input.line} errored (${hits[0]!.value}). Read the source around that line with ReadSource, then retry Logpoint with an expression that is in scope there (recording ${input.recordingId}).` }]
365        : []),
366      ...(hits[0] ? [{ kind: 'prompt' as const, label: 'Describe first hit', text: `Use the Replay MCP DescribePoint tool at ${hits[0].point} (recording ${input.recordingId}).` }] : []),
367    ]),
368  })
369}
370
371/** ListSources: how many sources, the app's own first, then the rest. */
372const listSources: CardParser = (input, text) => {
373  const count = num(/^(\d+) source\(s\) found/m.exec(text)?.[1])
374  const all = text.split('\n').map(l => /^- (.+)$/.exec(l)?.[1]?.trim()).filter((s): s is string => Boolean(s))
375  if (all.length === 0 && !Number.isFinite(count)) return genericCard('ListSources', input, text, 'source-scoped')
376  const named = all.filter(s => !/^SOURCE\d+$/.test(s))
377  const app = named.filter(s => !isLibrary(s) && /^https?:/.test(s))
378  const rest = named.filter(s => !app.includes(s))
379  const anonymous = all.length - named.length
380  const ordered = [...app, ...rest]
381  const item = (s: string) => ({ text: fileName(s), detail: shortPath(s), tone: (isLibrary(s) ? 'muted' : undefined) as Tone | undefined })
382  return card('ListSources', {
383    summary: `${Number.isFinite(count) ? count : all.length} sources${input.pathGlob ? ` matching ${input.pathGlob}` : ''}`,
384    status: all.length ? 'complete' : 'empty',
385    scope: 'source-scoped',
386    facts: [
387      { label: 'sources', value: String(Number.isFinite(count) ? count : all.length) },
388      { label: 'app files', value: String(app.length), tone: 'accent' },
389      ...(anonymous ? [{ label: 'unnamed', value: String(anonymous), tone: 'muted' as Tone }] : []),
390    ],
391    preview: ordered.length
392      ? [{ kind: 'list', items: ordered.slice(0, 12).map(item) }]
393      : [{ kind: 'text', text: `${anonymous} unnamed sources (eval'd or inline scripts) listed.`, tone: 'muted' }],
394    sections: [
395      ...(ordered.length > 12 ? [{ id: 'all', title: `All ${ordered.length} named sources`, blocks: [{ kind: 'list' as const, items: ordered.slice(0, 400).map(item) }] }] : []),
396      rawSection(text),
397    ],
398    actions: baseActions(input, text, [
399      ...(app[0] ? [{ kind: 'prompt' as const, label: `Read ${fileName(app[0])}`, text: `Use the Replay MCP ReadSource tool on ${app[0]} (recording ${input.recordingId}).` }] : []),
400    ]),
401  })
402}
403
404/** ReadSource: the lines around the requested one with how often each ran. */
405const readSource: CardParser = (input, text) => {
406  const lines = codeLines(text)
407  if (lines.length === 0) return genericCard('ReadSource', input, text, 'source-scoped')
408  const path = typeof input.path === 'string' ? input.path : ''
409  const marked = lines.find(l => l.isMarked)
410  const ran = lines.filter(l => l.hits && l.hits !== '0').length
411  const never = lines.filter(l => l.isUnhit).length
412  const preview = aroundMarked(lines, 5, 12)
413  return card('ReadSource', {
414    summary: `${fileName(path) || 'Source'} lines ${lines[0]!.n}–${lines[lines.length - 1]!.n}`,
415    status: 'complete',
416    scope: 'source-scoped',
417    facts: [
418      ...(marked ? [{ label: 'line', value: String(marked.n), tone: 'accent' as Tone }] : []),
419      ...(marked?.hits ? [{ label: 'hits there', value: marked.hits, tone: 'info' as Tone }] : []),
420      { label: 'lines ran', value: `${ran}/${lines.length}` },
421      ...(never ? [{ label: 'never ran', value: String(never), tone: 'warn' as Tone }] : []),
422    ].slice(0, 3),
423    preview: [{ kind: 'code', path: shortPath(path), lines: preview }],
424    sections: [
425      ...(lines.length > preview.length ? [{ id: 'all', title: `All ${lines.length} lines`, blocks: [{ kind: 'code' as const, path: shortPath(path), lines }] }] : []),
426      rawSection(text),
427    ],
428    actions: baseActions(input, text, [
429      ...(marked
430        ? [{ kind: 'prompt' as const, label: `Logpoint on line ${marked.n}`, text: `Use the Replay MCP Logpoint tool on ${path} line ${marked.n} (recording ${input.recordingId}) with an expression for the values used there.` }]
431        : []),
432    ]),
433  })
434}
435
436/** SearchSources: matches grouped by file, each a code snippet with hit counts. */
437const searchSources: CardParser = (input, text) => {
438  const files = text
439    .split(/^### /m)
440    .slice(1)
441    .map(chunk => {
442      const [url = '', ...rest] = chunk.split('\n')
443      return { url: url.trim(), lines: codeLines(rest.join('\n')) }
444    })
445    .filter(f => f.lines.length)
446  if (files.length === 0) {
447    if (!text.trim() || /no match/i.test(text)) {
448      return card('SearchSources', {
449        summary: `No matches for ${String(input.pattern ?? '')}`,
450        status: 'empty',
451        scope: 'source-scoped',
452        facts: [],
453        preview: [{ kind: 'text', text: 'Nothing in the recorded sources matched.', tone: 'muted' }],
454        sections: [],
455        actions: baseActions(input, text),
456      })
457    }
458    return genericCard('SearchSources', input, text, 'source-scoped')
459  }
460  const matches = files.reduce((n, f) => n + f.lines.filter(l => l.isMarked).length, 0)
461  // App files first, then by how many matching lines ran.
462  const ranked = [...files].sort(
463    (a, b) => Number(isLibrary(a.url)) - Number(isLibrary(b.url)) || b.lines.filter(l => l.isMarked && l.hits).length - a.lines.filter(l => l.isMarked && l.hits).length,
464  )
465  const block = (f: (typeof files)[number]): Block => ({ kind: 'code', path: shortPath(f.url), lines: f.lines.filter(l => l.isMarked).slice(0, 6) })
466  return card('SearchSources', {
467    summary: `"${String(input.pattern ?? '')}" in ${files.length} file${files.length === 1 ? '' : 's'}`,
468    status: 'complete',
469    scope: 'source-scoped',
470    facts: [
471      { label: 'matches', value: String(matches), tone: 'accent' },
472      { label: 'files', value: String(files.length) },
473      { label: 'app files', value: String(files.filter(f => !isLibrary(f.url)).length) },
474    ],
475    preview: ranked.slice(0, 2).map(block),
476    sections: [
477      ...ranked.slice(2).map((f, i) => ({ id: `f${i}`, title: fileName(f.url), blocks: [{ kind: 'code' as const, path: shortPath(f.url), lines: f.lines }] })),
478      { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: [{ label: 'pattern', value: String(input.pattern ?? '') }, ...(input.pathGlob ? [{ label: 'path glob', value: String(input.pathGlob) }] : [])] }] },
479      rawSection(text),
480    ],
481    actions: baseActions(input, text, [
482      ...(ranked[0]
483        ? [{ kind: 'prompt' as const, label: `Read ${fileName(ranked[0].url)}`, text: `Use the Replay MCP ReadSource tool on ${ranked[0].url} line ${ranked[0].lines.find(l => l.isMarked)?.n ?? 1} (recording ${input.recordingId}).` }]
484        : []),
485    ]),
486  })
487}
488
489/** ExecutionDelay: how long after the source loaded each part of it first ran. */
490const executionDelay: CardParser = (input, text) => {
491  const buckets = [...text.matchAll(/^(\d+-\d+ms|Never executed):\s*([\d.]+)%\s*\((\d+) chars\)/gm)].map(m => ({
492    label: m[1]!,
493    pct: Number(m[2]),
494    chars: Number(m[3]),
495  }))
496  const fns = [...text.matchAll(/^(\S.*?) \[(\d+:\d+-\d+:\d+)\]: length=(\d+), weight=(\d+), first breakpoint delay=(never executed|[\d.]+ms)/gm)].map(m => ({
497    name: m[1]!,
498    range: m[2]!,
499    length: m[3]!,
500    weight: m[4]!,
501    delay: m[5]!,
502  }))
503  if (buckets.length === 0 && fns.length === 0) return genericCard('ExecutionDelay', input, text, 'source-scoped')
504  const loaded = /Source downloaded at ([\d.]+)ms/.exec(text)?.[1]
505  const path = typeof input.path === 'string' ? input.path : /analysis for (\S+)/.exec(text)?.[1] ?? ''
506  const never = buckets.find(b => /never/i.test(b.label))
507  // Buckets are 100ms wide, so keep a tenth of a second on long delays.
508  const label = (l: string) => {
509    const m = /^(\d+)-\d+ms$/.exec(l)
510    if (!m) return l
511    const at = Number(m[1])
512    return at >= 1000 ? `+${(at / 1000).toFixed(1)}s` : `+${at}ms`
513  }
514  return card('ExecutionDelay', {
515    summary: `When ${fileName(path)} first ran, by share of its code`,
516    status: 'complete',
517    scope: 'source-scoped',
518    facts: [
519      ...(loaded ? [{ label: 'loaded at', value: ms(Number(loaded)), tone: 'info' as Tone }] : []),
520      { label: 'functions', value: String(fns.length || num(/Total functions: (\d+)/.exec(text)?.[1])) },
521      ...(never ? [{ label: 'never ran', value: `${never.pct}%`, tone: 'warn' as Tone }] : []),
522    ],
523    preview: [
524      {
525        kind: 'bars',
526        items: buckets.slice(0, 12).map(b => ({ label: label(b.label), value: b.pct, display: `${b.pct}%`, tone: (/never/i.test(b.label) ? 'warn' : 'accent') as Tone })),
527      },
528    ],
529    sections: [
530      {
531        id: 'functions',
532        title: `Functions (${fns.length})`,
533        blocks: [
534          {
535            kind: 'table',
536            columns: ['function', 'range', 'weight', 'first ran after'],
537            rows: fns.map(f => [f.name, f.range, f.weight, f.delay === 'never executed' ? 'never' : ms(num(f.delay))]),
538            numeric: [false, false, true, true],
539            tones: fns.map(f => (f.delay === 'never executed' ? 'muted' : undefined)),
540          },
541        ],
542      },
543      rawSection(text),
544    ],
545    actions: baseActions(input, text, [
546      { kind: 'prompt', label: 'Read the slow parts', text: `Use the Replay MCP ReadSource tool on ${path} at the functions that ran last or never ran (recording ${input.recordingId}), and explain why they were delayed.` },
547    ]),
548  })
549}
550
551export const CODE_PARSERS: Record<string, CardParser> = {
552  DescribePoint: describePoint,
553  Evaluate: evaluate,
554  GetStack: getStack,
555  GetPointComponent: getPointComponent,
556  GetPointLink: getPointLink,
557  InspectElement: inspectElement,
558  Logpoint: logpoint,
559  ListSources: listSources,
560  ReadSource: readSource,
561  SearchSources: searchSources,
562  ExecutionDelay: executionDelay,
563}
564
hooks/mcp/tools/overview.ts 426 lines
1import {
2  baseActions,
3  boldFacts,
4  bullets,
5  fact,
6  genericCard,
7  ms,
8  nextSteps,
9  num,
10  plain,
11  rawSection,
12  section,
13  sections,
14  tables,
15  truncate,
16  type Action,
17  type Block,
18  type Card,
19  type CardParser,
20  type Fact,
21  type Scope,
22  type Section,
23  type Tone,
24  type ToolInput,
25} from '../card.ts'
26
27// Recording-wide cards: the overview, console, network, user events, storage,
28// annotations, screenshots, Playwright steps and exceptions.
29
30const mode = (input: ToolInput, fallback: string) => (typeof input.mode === 'string' ? input.mode : fallback)
31
32const on = (input: ToolInput) => (input.recordingId ? ` on recording ${input.recordingId}` : '')
33
34const prompt = (label: string, text: string): Action => ({ kind: 'prompt', label, text })
35
36function card(tool: string, scope: Scope, input: ToolInput, text: string, parts: Partial<Card> & Pick<Card, 'summary'>, extra: Action[] = []): Card {
37  return {
38    tool,
39    title: tool,
40    status: 'complete',
41    scope,
42    facts: [],
43    preview: [],
44    ...parts,
45    sections: [...(parts.sections ?? []), rawSection(text)],
46    // Tool-specific actions replace the generic "Next:" prompts built from the text.
47    actions: baseActions(input, text, extra).filter(a => !extra.length || !a.label.startsWith('Next:')),
48  }
49}
50
51const levelTone = (level: string): Tone | undefined =>
52  /error/i.test(level) ? 'bad' : /warn/i.test(level) ? 'warn' : /info/i.test(level) ? 'info' : undefined
53
54const statusTone = (status: string): Tone | undefined => {
55  const n = num(status)
56  if (!Number.isFinite(n)) return /fail|error|abort/i.test(status) ? 'bad' : undefined
57  return n >= 400 ? 'bad' : n >= 300 ? 'muted' : 'good'
58}
59
60// ------------------------------------------------------- RecordingOverview
61
62/** Recording summary: environment, error/network/render counts, and the tools to try next. */
63const recordingOverview: CardParser = (input, text) => {
64  const facts = boldFacts(text)
65  const url = fact(facts, /^URL$/)
66  const duration = fact(facts, /^Duration$/)
67  const errors = num(fact(facts, /^Errors$/))
68  const warnings = num(fact(facts, /^Warnings$/))
69  const requests = fact(facts, /^Total requests$/)
70  const failed = num(fact(facts, /^Failed$/))
71  const commits = fact(facts, /^Total commits$/)
72  const env = section(text, /environment/i) ?? ''
73  const consumed: Block[] = [{ kind: 'facts', items: facts.slice(0, 14) }]
74  const shotTs = /timestamp[:\s`]+(\d+)/i.exec(section(text, /screenshot/i) ?? '')?.[1]
75  const steps = nextSteps(text)
76  const extra: Action[] = steps.filter(s => !(shotTs && /^Screenshot/.test(s))).slice(0, 2).map(s => {
77    const tool = /^([A-Z][A-Za-z]+)/.exec(s)?.[1] ?? 'the suggested tool'
78    return prompt(`Run ${tool}`, `Use the Replay MCP ${tool} tool${on(input)}: ${s}`)
79  })
80  return card(
81    'RecordingOverview',
82    'recording-wide',
83    input,
84    text,
85    {
86      summary: url ? truncate(url, 70) : truncate(plain(bullets(env)[0] ?? 'Recording summary'), 70),
87      facts: [
88        ...(duration ? [{ label: 'duration', value: duration }] : []),
89        {
90          label: 'errors / warnings',
91          value: `${Number.isFinite(errors) ? errors : '?'} / ${Number.isFinite(warnings) ? warnings : '?'}`,
92          tone: errors > 0 ? 'bad' : warnings > 0 ? 'warn' : 'good',
93        },
94        ...(requests ? [{ label: 'requests', value: `${requests}${failed > 0 ? ` (${failed} failed)` : ''}`, tone: (failed > 0 ? 'bad' : 'good') as Tone }] : []),
95      ],
96      preview: [
97        {
98          kind: 'list',
99          items: [
100            ...bullets(env).filter(b => !/^(URL|Duration):/.test(b)).map(b => ({ text: b })),
101            ...(commits ? [{ text: `React: ${commits} commits, ${fact(facts, /render time/i) ?? '?'} render time` }] : []),
102          ].slice(0, 6),
103        },
104      ],
105      sections: [
106        ...(steps.length ? [{ id: 'next', title: 'Suggested next steps', blocks: [{ kind: 'list', items: steps.map(s => ({ text: s, tone: 'accent' as Tone })) }] as Block[] }] : []),
107        ...(shotTs ? [{ id: 'shot', title: 'Final screenshot', blocks: [{ kind: 'text', text: `Final state at ${ms(Number(shotTs))}. Run Screenshot with timestamp ${shotTs} to see it.` }] as Block[] }] : []),
108        ...sections(text)
109          .filter(s => /network|render|api/i.test(s.title))
110          .map((s, i) => ({ id: `s${i}`, title: s.title, blocks: [{ kind: 'markdown', text: s.body.trim() }] as Block[] })),
111        { id: 'consumed', title: 'Consumed data', blocks: consumed },
112      ],
113    },
114    [...extra, ...(shotTs ? [prompt('Show final screenshot', `Use the Replay MCP Screenshot tool with timestamp ${shotTs}${on(input)}.`)] : [])],
115  )
116}
117
118// --------------------------------------------------------- ConsoleMessages
119
120type ConsoleRow = { level: string; point: string; text: string }
121
122/** `[console.warning] (Point:1): "text"` lines. */
123function consoleRows(text: string): ConsoleRow[] {
124  const rows: ConsoleRow[] = []
125  for (const line of text.split('\n')) {
126    const m = /^\[([^\]]+)\]\s*\((Point:\d+)\):\s*(.*)$/.exec(line.trim())
127    if (m) rows.push({ level: m[1]!.replace(/^console\./, ''), point: m[2]!, text: m[3]!.replace(/^"|"$/g, '').replace(/" "/g, ' ') })
128  }
129  return rows
130}
131
132const consoleList = (rows: ConsoleRow[]): Block => ({
133  kind: 'list',
134  items: rows.map(r => ({ badge: r.level.slice(0, 4).toUpperCase(), tone: levelTone(r.level), text: truncate(r.text, 160), detail: r.point })),
135})
136
137/** Console output: level counts, the latest problems, and drill-in to one message. */
138const consoleMessages: CardParser = (input, text) => {
139  const m = mode(input, 'summary')
140  if (m === 'message-detail') {
141    const level = /Level:\s*(.+)/.exec(text)?.[1]?.trim() ?? 'message'
142    const point = /Point:\s*(Point:\d+|\S+)/.exec(text)?.[1] ?? String(input.point ?? '')
143    const content = /Content:\s*\n([\s\S]*?)(?:\n\s*\n|\nStack trace:|$)/.exec(text)?.[1]?.trim().replace(/^"|"$/g, '') ?? ''
144    const stack = /Stack trace:\s*\n([\s\S]*)$/.exec(text)?.[1] ?? ''
145    const frames = stack.split('\n').map(l => /^\s*at\s+(\S+)\s+\((.+)\)/.exec(l)).filter((x): x is RegExpExecArray => Boolean(x))
146    return card('ConsoleMessages', 'point-scoped', input, text, {
147      summary: truncate(content || 'Console message', 80),
148      facts: [
149        { label: 'level', value: level.replace(/^console\./, ''), tone: levelTone(level) },
150        { label: 'point', value: point },
151        ...(frames.length ? [{ label: 'frames', value: String(frames.length) }] : []),
152      ],
153      preview: [
154        { kind: 'text', text: content, tone: levelTone(level) },
155        ...(frames.length ? [{ kind: 'list', items: frames.map(f => ({ text: f[1]!, detail: f[2]! })) } as Block] : []),
156      ],
157      sections: [{ id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: [{ label: 'point', value: point }, { label: 'source', value: /Source:\s*(.+)/.exec(text)?.[1] ?? '?' }] }] }],
158    }, point ? [
159      prompt('Describe this point', `Use the Replay MCP DescribePoint tool with point ${point}${on(input)}.`),
160      prompt('Get the stack', `Use the Replay MCP GetStack tool with point ${point}${on(input)}.`),
161    ] : [])
162  }
163  const rows = consoleRows(text)
164  if (!rows.length && !/Summary|console messages/i.test(text)) return genericCard('ConsoleMessages', input, text)
165  const total = num(/(\d+)\s+(?:total\s+)?(?:console\s+)?messages/i.exec(text)?.[0])
166  const breakdown = /Breakdown:\s*(.+)/.exec(text)?.[1] ?? ''
167  // The summary's "Breakdown: 12 warnings, 9 info"; otherwise count the rows shown.
168  const count = (word: RegExp, level: RegExp) => {
169    const m = word.exec(breakdown)
170    return m ? Number(m[1]) : rows.filter(r => level.test(r.level)).length
171  }
172  const errors = count(/(\d+)\s+errors?/, /error/)
173  const warnings = count(/(\d+)\s+warnings?/, /warn/)
174  const firstError = rows.find(r => /error/.test(r.level)) ?? rows.find(r => /warn/.test(r.level))
175  return card('ConsoleMessages', 'recording-wide', input, text, {
176    summary: m === 'messages' ? `${Number.isFinite(total) ? total : rows.length} messages${input.level && input.level !== 'all' ? ` (${String(input.level)})` : ''}` : breakdown || 'Console summary',
177    status: Number.isFinite(total) && total === 0 && !rows.length ? 'empty' : 'complete',
178    facts: [
179      ...(Number.isFinite(total) ? [{ label: 'messages', value: String(total) }] : []),
180      { label: 'errors', value: String(errors), tone: errors > 0 ? 'bad' : 'good' },
181      { label: 'warnings', value: String(warnings), tone: warnings > 0 ? 'warn' : undefined },
182    ] as Fact[],
183    preview: rows.length ? [consoleList(rows.slice(0, 8))] : [{ kind: 'text', text: 'No console messages.', tone: 'muted' }],
184    sections: [
185      ...(rows.length > 8 ? [{ id: 'all', title: `All ${rows.length} shown messages`, blocks: [consoleList(rows)] }] : []),
186      { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: [{ label: 'mode', value: m }, { label: 'points', value: truncate(rows.map(r => r.point).join(', '), 200) || 'none' }] }] },
187    ],
188  }, firstError ? [prompt(`Inspect ${firstError.point}`, `Use the Replay MCP ConsoleMessages tool with mode 'message-detail' and point ${firstError.point}${on(input)}.`)] : [])
189}
190
191// ---------------------------------------------------------- NetworkRequest
192
193/** Network: summary counts and status mix, the paged request table, or one request's detail. */
194const networkRequest: CardParser = (input, text) => {
195  const m = mode(input, 'summary')
196  const facts = boldFacts(text)
197  const [first, ...rest] = tables(text)
198  if (m === 'requests' && first) {
199    const ci = (re: RegExp) => first.columns.findIndex(c => re.test(c))
200    const [iTime, iMethod, iUrl, iStatus, iDur, iId] = [ci(/time/i), ci(/method/i), ci(/url/i), ci(/status/i), ci(/duration/i), ci(/^id$/i)]
201    const pick = (r: string[], i: number) => (i >= 0 ? r[i] ?? '' : '')
202    const rows = first.rows.map(r => [pick(r, iMethod), pick(r, iStatus), pick(r, iUrl), pick(r, iDur), pick(r, iTime)])
203    const bad = first.rows.filter(r => num(pick(r, iStatus)) >= 400)
204    const slowest = [...first.rows].sort((a, b) => num(pick(b, iDur)) - num(pick(a, iDur)))[0]
205    const shown = /Showing\s+([\d–-]+)\s+of\s+(\d+)/.exec(text)
206    return card('NetworkRequest', 'recording-wide', input, text, {
207      summary: shown ? `Requests ${shown[1]} of ${shown[2]}` : `${first.rows.length} requests`,
208      facts: [
209        { label: 'shown', value: String(first.rows.length) },
210        { label: 'failed', value: String(bad.length), tone: bad.length ? 'bad' : 'good' },
211        ...(slowest ? [{ label: 'slowest', value: `${pick(slowest, iDur)} ${truncate(pick(slowest, iUrl), 30)}` }] : []),
212      ],
213      preview: [{ kind: 'table', columns: ['Method', 'Status', 'URL', 'Duration', 'Time'], rows: rows.slice(0, 10), numeric: [false, false, false, true, true], tones: first.rows.slice(0, 10).map(r => statusTone(pick(r, iStatus))) }],
214      sections: [
215        ...(rows.length > 10 ? [{ id: 'all', title: `All ${rows.length} rows`, blocks: [{ kind: 'table', columns: ['Method', 'Status', 'URL', 'Duration', 'Time'], rows, numeric: [false, false, false, true, true], tones: first.rows.map(r => statusTone(pick(r, iStatus))) }] as Block[] }] : []),
216        { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: [{ label: 'request ids', value: truncate(first.rows.map(r => pick(r, iId)).join(', '), 200) }] }] },
217      ],
218    }, [
219      bad[0]
220        ? prompt('Inspect failed request', `Use the Replay MCP NetworkRequest tool with mode 'detail' and requestId ${pick(bad[0], iId)}${on(input)}.`)
221        : slowest ? prompt('Inspect slowest request', `Use the Replay MCP NetworkRequest tool with mode 'detail' and requestId ${pick(slowest, iId)}${on(input)}.`) : prompt('Next page', `Use the Replay MCP NetworkRequest tool with mode 'requests' and offset ${first.rows.length}${on(input)}.`),
222    ])
223  }
224  if (m === 'summary' && facts.length) {
225    const total = fact(facts, /total requests/i)
226    const failed = num(fact(facts, /failed/i))
227    const slow = num(fact(facts, /slow/i))
228    const status = tables(section(text, /status/i) ?? '')[0]
229    const types = tables(section(text, /request types/i) ?? '')[0]
230    const domains = tables(section(text, /domains/i) ?? '')[0]
231    const toBars = (t: typeof status, toneFor?: (k: string) => Tone | undefined): Block | undefined =>
232      t && { kind: 'bars', items: t.rows.map(r => ({ label: r[0] ?? '', value: num(r[1]), display: r[1] ?? '', tone: toneFor?.(r[0] ?? '') })) }
233    const statusBars = toBars(status, k => (/^[45]/.test(k) ? 'bad' : /^2/.test(k) ? 'good' : 'muted'))
234    return card('NetworkRequest', 'recording-wide', input, text, {
235      summary: fact(facts, /response times/i) ? `Response times: ${fact(facts, /response times/i)}` : 'Network summary',
236      facts: [
237        { label: 'requests', value: total ?? '?' },
238        { label: 'failed', value: String(Number.isFinite(failed) ? failed : 0), tone: failed > 0 ? 'bad' : 'good' },
239        { label: 'slow', value: String(Number.isFinite(slow) ? slow : 0), tone: slow > 0 ? 'warn' : undefined },
240      ],
241      preview: statusBars ? [statusBars] : [{ kind: 'facts', items: facts.slice(0, 5) }],
242      sections: [
243        ...(types ? [{ id: 'types', title: 'Request types', blocks: [toBars(types)!] }] : []),
244        ...(domains ? [{ id: 'domains', title: 'Top domains', blocks: [toBars(domains)!] }] : []),
245        { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: facts }] },
246      ],
247    }, [prompt(failed > 0 ? 'List failed requests' : 'Browse requests', `Use the Replay MCP NetworkRequest tool with mode 'requests'${failed > 0 ? ' and focus on failed (status >= 400) requests' : ''}${on(input)}.`)])
248  }
249  if (m === 'detail') {
250    const g = genericCard('NetworkRequest', input, text, 'request-scoped')
251    const status = fact(facts, /status/i)
252    return { ...g, summary: truncate(fact(facts, /url/i) ?? g.summary, 80), facts: facts.slice(0, 3).map(f => (/status/i.test(f.label) ? { ...f, tone: statusTone(status ?? '') } : f)) }
253  }
254  void rest
255  return genericCard('NetworkRequest', input, text)
256}
257
258// -------------------------------------------------------- UserInteractions
259
260/** User events: the click/key timeline, or the list of interactions with their points. */
261const userInteractions: CardParser = (input, text) => {
262  const facts = boldFacts(text)
263  const table = tables(text)[0]
264  if (mode(input, 'summary') === 'interactions' && table) {
265    const ci = (re: RegExp) => table.columns.findIndex(c => re.test(c))
266    const [iTime, iType, iDetail, iPoint] = [ci(/time/i), ci(/type/i), ci(/detail/i), ci(/point/i)]
267    const items = table.rows.map(r => ({ badge: (r[iType] ?? '').toUpperCase().slice(0, 5), tone: 'info' as Tone, text: `${r[iTime] ?? ''}  ${r[iDetail] ?? ''}`, detail: r[iPoint] ?? '' }))
268    const firstPoint = table.rows[0]?.[iPoint]
269    return card('UserInteractions', 'recording-wide', input, text, {
270      summary: /Showing[^*]+/.exec(text)?.[0]?.trim() ?? `${table.rows.length} interactions`,
271      facts: [{ label: 'interactions', value: String(table.rows.length), tone: 'info' }],
272      preview: [{ kind: 'list', items: items.slice(0, 10) }],
273      sections: [
274        ...(items.length > 10 ? [{ id: 'all', title: `All ${items.length}`, blocks: [{ kind: 'list', items }] as Block[] }] : []),
275        { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'table', columns: table.columns, rows: table.rows }] },
276      ],
277    }, firstPoint ? [prompt(`Describe ${firstPoint}`, `Use the Replay MCP DescribePoint tool with point ${firstPoint}${on(input)} to see the code that handled this interaction.`)] : [])
278  }
279  if (!facts.length) return genericCard('UserInteractions', input, text)
280  const timeline = tables(section(text, /timeline/i) ?? '')[0]
281  return card('UserInteractions', 'recording-wide', input, text, {
282    summary: fact(facts, /time range/i) ? `Between ${fact(facts, /time range/i)}` : 'User interactions',
283    status: num(fact(facts, /total/i)) === 0 ? 'empty' : 'complete',
284    facts: [
285      { label: 'interactions', value: fact(facts, /total/i) ?? '?', tone: 'info' },
286      { label: 'clicks', value: fact(facts, /clicks/i) ?? '0' },
287      { label: 'keys', value: fact(facts, /key/i) ?? '0' },
288    ],
289    preview: timeline
290      ? [{ kind: 'bars', items: timeline.rows.map(r => ({ label: r[0] ?? '', value: num(r[r.length - 1]), display: r[r.length - 1] ?? '', tone: 'info' as Tone })) }]
291      : [],
292    sections: [{ id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: facts }] }],
293  }, [prompt('List interactions', `Use the Replay MCP UserInteractions tool with mode 'interactions'${on(input)}.`)])
294}
295
296// ------------------------------------------------------------ LocalStorage
297
298/** localStorage reads/writes: counts, or the operation list. */
299const localStorage: CardParser = (input, text) => {
300  const facts = boldFacts(text)
301  const total = num(fact(facts, /total/i))
302  const table = tables(text)[0]
303  if (total === 0 || /No localStorage accesses/i.test(text)) {
304    return card('LocalStorage', 'recording-wide', input, text, { summary: 'No localStorage accesses', status: 'empty', facts: [{ label: 'accesses', value: '0' }], preview: [{ kind: 'text', text: 'The app did not read or write localStorage.', tone: 'muted' }] })
305  }
306  if (!facts.length && !table) return genericCard('LocalStorage', input, text)
307  return card('LocalStorage', 'recording-wide', input, text, {
308    summary: mode(input, 'summary') === 'operations' ? 'Storage operations' : 'Storage summary',
309    facts: facts.slice(0, 3),
310    preview: table ? [{ kind: 'table', columns: table.columns, rows: table.rows.slice(0, 10) }] : [{ kind: 'facts', items: facts }],
311    sections: [{ id: 'consumed', title: 'Consumed data', blocks: table ? [{ kind: 'table', columns: table.columns, rows: table.rows }] : [{ kind: 'facts', items: facts }] }],
312  }, [prompt('List operations', `Use the Replay MCP LocalStorage tool with mode 'operations'${on(input)}.`)])
313}
314
315// ------------------------------------------------------------- Annotations
316
317/** Annotation kinds with counts, or one kind's timestamped entries. */
318const annotations: CardParser = (input, text) => {
319  const kinds = [...text.matchAll(/^•\s*(.+?):\s*(\d+)\s+annotations?/gm)].map(m => ({ kind: m[1]!, count: Number(m[2]) }))
320  if (kinds.length) {
321    return card('Annotations', 'recording-wide', input, text, {
322      summary: `${kinds.length} annotation kinds`,
323      facts: [{ label: 'kinds', value: String(kinds.length) }, { label: 'annotations', value: String(kinds.reduce((a, k) => a + k.count, 0)) }],
324      preview: [{ kind: 'bars', items: kinds.slice(0, 10).map(k => ({ label: k.kind, value: k.count, display: String(k.count) })) }],
325      sections: [{ id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'list', items: kinds.map(k => ({ text: k.kind, detail: String(k.count) })) }] }],
326    }, [prompt(`Show ${truncate(kinds[0]!.kind, 24)}`, `Use the Replay MCP Annotations tool with kind "${kinds[0]!.kind}"${on(input)}.`)])
327  }
328  const entries = [...text.matchAll(/^\[(\d+)\]\s+(\d+)ms\s+\((Point:\d+)\):\s*(.*)$/gm)]
329  if (entries.length) {
330    const header = /^(\d+)\s+"([^"]+)"\s+annotation/m.exec(text)
331    return card('Annotations', 'recording-wide', input, text, {
332      summary: header ? `${header[1]} “${header[2]}” annotations` : 'Annotations',
333      facts: [{ label: 'shown', value: String(entries.length) }, ...(header ? [{ label: 'total', value: header[1]! }] : [])],
334      preview: [{ kind: 'list', items: entries.slice(0, 10).map(e => ({ text: truncate(e[4]!, 140), detail: `${ms(Number(e[2]))} ${e[3]}` })) }],
335      sections: [{ id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'list', items: entries.map(e => ({ text: e[4]!, detail: `${e[2]}ms ${e[3]}` })) }] }],
336    })
337  }
338  return genericCard('Annotations', input, text)
339}
340
341// -------------------------------------------------------------- Screenshot
342
343/** The screenshot image at a moment, with the mouse position and next point. */
344const screenshot: CardParser = (input, text) => {
345  const m = /Screenshot at timestamp\s+(\d+):\s*(\S+)/.exec(text)
346  if (!m) return genericCard('Screenshot', input, text)
347  const mouse = /Mouse position:\s*(.+)/.exec(text)?.[1]
348  const sinceClick = /Time since last click:\s*([\d.]+)ms/.exec(text)?.[1]
349  const next = /Next execution point:\s*(Point:\d+)\s*@\s*([\d.]+)ms/.exec(text)
350  return card('Screenshot', 'point-scoped', input, text, {
351    summary: `At ${ms(Number(m[1]))}`,
352    facts: [
353      { label: 'time', value: ms(Number(m[1])) },
354      ...(mouse ? [{ label: 'mouse', value: mouse }] : []),
355      ...(sinceClick ? [{ label: 'since last click', value: ms(Number(sinceClick)) }] : []),
356    ],
357    preview: [{ kind: 'image', url: m[2]!, caption: `Screenshot at ${ms(Number(m[1]))}` }],
358    sections: [{ id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'facts', items: [{ label: 'url', value: m[2]! }, ...(next ? [{ label: 'next point', value: `${next[1]} @ ${ms(Number(next[2]))}` }] : [])] }] }],
359  }, [
360    { kind: 'url', label: 'Open image', url: m[2]! },
361    ...(next ? [prompt(`Describe ${next[1]}`, `Use the Replay MCP DescribePoint tool with point ${next[1]}${on(input)}.`)] : []),
362  ])
363}
364
365// --------------------------------------------------------- PlaywrightSteps
366
367/** Playwright tests and steps, with failures flagged. */
368const playwrightSteps: CardParser = (input, text) => {
369  if (/No playwright steps found/i.test(text) || !text.trim()) {
370    return card('PlaywrightSteps', 'recording-wide', input, text, { summary: 'No Playwright steps in this recording', status: 'empty', preview: [{ kind: 'text', text: 'This recording was not made by a Playwright test.', tone: 'muted' }] })
371  }
372  const table = tables(text)[0]
373  const g = genericCard('PlaywrightSteps', input, text)
374  if (!table) return g
375  const failed = table.rows.filter(r => r.some(c => /fail|error|✗|❌/i.test(c)))
376  return {
377    ...g,
378    facts: [{ label: 'rows', value: String(table.rows.length) }, { label: 'failed', value: String(failed.length), tone: failed.length ? 'bad' : 'good' }],
379    preview: [{ kind: 'table', columns: table.columns, rows: table.rows.slice(0, 10), tones: table.rows.slice(0, 10).map(r => (failed.includes(r) ? 'bad' : undefined)) }],
380  }
381}
382
383// ------------------------------------------------------------ Exceptions
384
385/** Uncaught exceptions / React render failures: message and stack, or a calm empty card. */
386function exceptionParser(tool: 'UncaughtException' | 'ReactException', noun: string): CardParser {
387  return (input, text) => {
388    if (!text.trim() || /^no (uncaught|react)/i.test(text.trim())) {
389      return card(tool, 'recording-wide', input, text, { summary: `No ${noun}`, status: 'empty', preview: [{ kind: 'text', text: `No ${noun} happened in this recording.`, tone: 'good' }] })
390    }
391    const g = genericCard(tool, input, text, 'point-scoped')
392    const message = boldFacts(text).find(f => /message|error/i.test(f.label))?.value ?? plain(text.split('\n').find(l => l.trim() && !l.startsWith('#')) ?? '')
393    const frames = text.split('\n').map(l => /^\s*(?:\d+\.\s*)?at\s+(.+)$/.exec(l)?.[1]).filter((x): x is string => Boolean(x))
394    const point = /Point:\d+/.exec(text)?.[0]
395    const preview: Block[] = [
396      { kind: 'text', text: truncate(message, 240), tone: 'bad' },
397      ...(frames.length ? [{ kind: 'list', items: frames.slice(0, 6).map(f => ({ text: f })) } as Block] : []),
398    ]
399    const extra: Action[] = point
400      ? [prompt(`Describe ${point}`, `Use the Replay MCP DescribePoint tool with point ${point}${on(input)} to explain this ${noun.replace(/s$/, '')}.`)]
401      : [prompt('Find the root cause', `Find the root cause of this ${noun.replace(/s$/, '')} using the Replay MCP tools${on(input)}.`)]
402    return {
403      ...g,
404      summary: truncate(message || g.summary, 80),
405      status: 'complete',
406      facts: [{ label: 'exception', value: truncate(message, 40), tone: 'bad' }, ...(frames.length ? [{ label: 'frames', value: String(frames.length) }] : []), ...(point ? [{ label: 'point', value: point }] : [])],
407      preview,
408      actions: baseActions(input, text, extra),
409      sections: g.sections.some(s => s.id === 'consumed') ? g.sections : [{ id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'markdown', text: truncate(text, 3000) }] } as Section, ...g.sections],
410    }
411  }
412}
413
414export const OVERVIEW_PARSERS: Record<string, CardParser> = {
415  RecordingOverview: recordingOverview,
416  ConsoleMessages: consoleMessages,
417  NetworkRequest: networkRequest,
418  UserInteractions: userInteractions,
419  LocalStorage: localStorage,
420  Annotations: annotations,
421  Screenshot: screenshot,
422  PlaywrightSteps: playwrightSteps,
423  UncaughtException: exceptionParser('UncaughtException', 'uncaught exceptions'),
424  ReactException: exceptionParser('ReactException', 'React render exceptions'),
425}
426
hooks/mcp/tools/react.ts 447 lines
1// Cards for the React, state-store and profiling tools.
2
3import {
4  baseActions,
5  boldFacts,
6  bullets,
7  errorCard,
8  fact,
9  genericCard,
10  ms,
11  num,
12  plain,
13  rawSection,
14  section,
15  sections,
16  type MdTable,
17  truncate,
18  type Action,
19  type Block,
20  type Card,
21  type CardParser,
22  type Scope,
23  type Section,
24  type Tone,
25  type ToolInput,
26} from '../card.ts'
27
28const mode = (input: ToolInput, fallback: string) => String(input.mode ?? fallback)
29
30/** "77.9ms", "750µs", "1.2s" -> milliseconds. */
31function durMs(s: string | undefined): number {
32  const m = /(-?[\d,.]+)\s*(µs|us|ms|s)\b/.exec(s ?? '')
33  if (!m) return NaN
34  const v = Number(m[1]!.replace(/,/g, ''))
35  return m[2] === 's' ? v * 1000 : m[2] === 'ms' ? v : v / 1000
36}
37
38/** Library code (node_modules, vite deps, chunks) is drawn muted next to app code. */
39const isLibrary = (s: string) => /node_modules|\.vite\/deps|chunk-[A-Z0-9]+\.js/.test(s)
40
41/** Bundler-mangled export names (`$ea39…$export$439d…`). */
42const isMangled = (s: string) => /^\$[0-9a-f]+\$export\$/.test(s)
43
44/**
45 * Pipe tables, tolerating separator cells with no dashes (`| : | -----: |`),
46 * which ReactRenders' commits table uses and card.ts `tables` skips.
47 */
48function pipeTables(text: string): MdTable[] {
49  const out: MdTable[] = []
50  const lines = text.split('\n')
51  const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => plain(c.trim()))
52  for (let i = 0; i < lines.length - 1; i++) {
53    const rule = lines[i + 1]!
54    if (!lines[i]!.includes('|') || !rule.includes('-') || !/^\s*\|?(\s*:?-*:?\s*\|)+\s*:?-*:?\s*\|?\s*$/.test(rule)) continue
55    const rows: string[][] = []
56    let j = i + 2
57    for (; j < lines.length && lines[j]!.includes('|') && lines[j]!.trim() !== ''; j++) rows.push(cells(lines[j]!))
58    out.push({ columns: cells(lines[i]!), rows })
59    i = j - 1
60  }
61  return out
62}
63
64const notFound = (text: string) => /not found in this recording|requires a recording/i.test(text)
65
66function card(tool: string, scope: Scope, input: ToolInput, text: string, c: Partial<Card> & Pick<Card, 'summary'>, extra: Action[] = []): Card {
67  return {
68    tool,
69    title: tool,
70    status: 'complete',
71    scope,
72    facts: [],
73    preview: [],
74    sections: [rawSection(text)],
75    ...c,
76    actions: baseActions(input, text, extra),
77  }
78}
79
80const prompt = (label: string, input: ToolInput, ask: string): Action => ({
81  kind: 'prompt',
82  label,
83  text: `${ask}${input.recordingId ? ` (Replay recording ${input.recordingId})` : ''}`,
84})
85
86// ------------------------------------------------------------- ReactRenders
87
88/** Summary: costliest commits as bars. commits: the paginated table. commit: one commit's anatomy. */
89const reactRenders: CardParser = (input, text) => {
90  const tool = 'ReactRenders'
91  const m = mode(input, 'summary')
92  const facts = boldFacts(text)
93
94  if (/^#\s*Commit #\d+/m.test(text) || m === 'commit') {
95    const index = /#\s*Commit #(\d+)/.exec(text)?.[1] ?? String(input.commitIndex ?? '?')
96    const components = pipeTables(section(text, /^Components/) ?? '')[0]
97    const rows = (components?.rows ?? [])
98      .map(r => ({ name: r[0] ?? '', renders: num(r[2]), wasted: num(r[3]), d: durMs(r[4]) }))
99      .filter(r => Number.isFinite(r.d))
100      .sort((a, b) => b.d - a.d)
101    const triggers = sections(text).find(s => /^Triggers/.test(s.title))?.body ?? ''
102    const triggerItems = triggers
103      .split(/\n(?=- \*\*)/)
104      .map(t => t.trim())
105      .filter(t => t.startsWith('- **'))
106      .map(t => {
107        const kind = plain(/- \*\*([^*]+)\*\*/.exec(t)?.[1] ?? 'Trigger')
108        const source = /Source:\s*(.+)/.exec(t)?.[1]
109        const point = /Point:\s*(\S+)/.exec(t)?.[1]
110        return { text: kind, detail: [source, point && `point ${truncate(point, 14)}`].filter(Boolean).join(' · ') }
111      })
112    const phase = boldFacts(section(text, /Phase Breakdown/) ?? '')
113    const mutations = bullets(section(text, /^DOM Mutations/) ?? '')
114    const fibers = fact(facts, /Fibers rendered/) ?? ''
115    return card(tool, 'profile-scoped', input, text, {
116      title: `${tool} · commit #${index}`,
117      summary: `${fact(facts, /Render duration/) ?? '?'} render at ${fact(facts, /Timestamp/) ?? '?'}`,
118      facts: [
119        { label: 'render', value: fact(facts, /Render duration/) ?? '?', tone: durMs(fact(facts, /Render duration/)) > 16 ? 'warn' : undefined },
120        { label: 'fibers', value: fibers.replace(/\s*\(.*$/, '') },
121        { label: 'wasted', value: /(\d+) wasted/.exec(fibers)?.[1] ?? '0', tone: /— ([\d.]+)%/.exec(fibers) && num(/— ([\d.]+)%/.exec(fibers)![1]) > 50 ? 'warn' : undefined },
122      ],
123      preview: rows.length
124        ? [{ kind: 'bars', items: rows.slice(0, 8).map(r => ({ label: r.name, value: r.d, display: `${ms(r.d)}${r.wasted ? ` · ${r.wasted} wasted` : ''}`, tone: isMangled(r.name) ? 'muted' : r.wasted > 0 && r.wasted === r.renders ? 'warn' : undefined })) }]
125        : [],
126      sections: [
127        { id: 'triggers', title: `Triggers (${triggerItems.length})`, blocks: [{ kind: 'list', items: triggerItems }] },
128        { id: 'phases', title: 'Phase breakdown', blocks: [{ kind: 'facts', items: phase }] },
129        ...(components ? [{ id: 'components', title: `Components (${components.rows.length})`, blocks: [{ kind: 'table' as const, columns: components.columns, rows: components.rows.slice(0, 40), numeric: components.columns.map((_, i) => i > 0) }] }] : []),
130        ...(mutations.length ? [{ id: 'mutations', title: 'DOM mutations', blocks: [{ kind: 'list' as const, items: mutations.map(t => ({ text: t })) }] }] : []),
131        rawSection(text),
132      ],
133    }, rows[0] ? [prompt(`Why ${truncate(rows[0].name, 18)}?`, input, `Use ReactRenders mode=component&componentName=${rows[0].name} to find out why ${rows[0].name} re-rendered in commit #${index}`)] : [])
134  }
135
136  const table = pipeTables(text)[0]
137  if (m === 'commits' || (table && /Duration/.test(table.columns.join(' ')) && /Trigger/.test(table.columns.join(' ')))) {
138    if (!table) return genericCard(tool, input, text, 'profile-scoped')
139    const col = (re: RegExp) => table.columns.findIndex(c => re.test(c))
140    const wi = col(/Waste%/)
141    const di = col(/Duration/)
142    const heaviest = [...table.rows].sort((a, b) => durMs(b[di]) - durMs(a[di]))[0]
143    const showing = /Showing ([^\n]+)/.exec(text)?.[1]
144    return card(tool, 'profile-scoped', input, text, {
145      title: `${tool} · commits`,
146      summary: showing ? plain(showing) : `${table.rows.length} commits`,
147      facts: heaviest ? [{ label: 'heaviest', value: `#${heaviest[0]} ${heaviest[di]}`, tone: durMs(heaviest[di]) > 16 ? 'warn' : undefined }] : [],
148      preview: [{
149        kind: 'table',
150        columns: table.columns,
151        rows: table.rows.slice(0, 10),
152        numeric: table.columns.map(c => /^#$|Time|Duration|Fibers|Mutating|Wasted|Waste%/.test(c)),
153        tones: table.rows.slice(0, 10).map((r): Tone | undefined => (num(r[wi]) > 50 ? 'warn' : undefined)),
154      }],
155      sections: [
156        ...(table.rows.length > 10 ? [{ id: 'all', title: `All ${table.rows.length} rows`, blocks: [{ kind: 'table' as const, columns: table.columns, rows: table.rows }] }] : []),
157        rawSection(text),
158      ],
159    }, heaviest ? [prompt(`Inspect commit #${heaviest[0]}`, input, `Use ReactRenders mode=commit&commitIndex=${heaviest[0]} to inspect the heaviest commit`)] : [])
160  }
161
162  if (m === 'summary' || /React Renders Summary/.test(text)) {
163    const commits = [...text.matchAll(/^\s*(\d+)\.\s+t=([\d.]+s)\s+—\s+([\d.]+ms) render time,\s+(\d+) fibers\s+—\s+(.+)$/gm)]
164    const top = commits[0]
165    return card(tool, 'profile-scoped', input, text, {
166      summary: 'React commit profile',
167      facts: [
168        { label: 'commits', value: fact(facts, /^Commits/) ?? '?' },
169        { label: 'render time', value: fact(facts, /render time/i) ?? '?' },
170      ],
171      preview: commits.length
172        ? [{ kind: 'bars', items: commits.map(c => ({ label: `#${c[1]} @ ${c[2]} ${plain(c[5]!)}`, value: num(c[3]), display: `${c[3]} · ${c[4]} fibers`, tone: num(c[3]) > 16 ? 'warn' : undefined })) }]
173        : [],
174      sections: [rawSection(text)],
175    }, top ? [prompt(`Inspect commit #${top[1]}`, input, `Use ReactRenders mode=commit&commitIndex=${top[1]} to inspect the costliest React commit`)] : [])
176  }
177
178  // component / waste-rank and other modes: tables and facts, generically.
179  return genericCard(tool, input, text, 'profile-scoped')
180}
181
182// ------------------------------------------------------- ReactComponentTree
183
184/** Summary: instance counts as bars. tree/subtree: the ▼/▶ hierarchy. */
185const reactComponentTree: CardParser = (input, text) => {
186  const tool = 'ReactComponentTree'
187  const treeLines = text.split('\n').filter(l => /^\s*[▼▶]\s/.test(l))
188  if (treeLines.length) {
189    const lines = treeLines.map(l => {
190      const indent = /^\s*/.exec(l)![0].length
191      const m = /^\s*([▼▶])\s+(.*?)(?:\s+\((Fiber:\d+)\))?(?:\s+\[(.+)\])?\s*$/.exec(l)!
192      return { depth: Math.floor(indent / 2), text: m[2] ?? l.trim(), detail: [m[3], m[4]].filter(Boolean).join(' '), isOpen: m[1] === '▼' }
193    })
194    const header = text.split('\n').find(l => l.trim() && !/^\s*[▼▶]/.test(l)) ?? ''
195    const visible = /\((\d+) visible of (\d+) total\)/.exec(header)
196    return card(tool, 'component-scoped', input, text, {
197      summary: header ? plain(header) : 'Component hierarchy',
198      facts: visible ? [{ label: 'visible', value: visible[1]! }, { label: 'components', value: visible[2]! }] : [],
199      preview: [{ kind: 'tree', lines: lines.slice(0, 14) }],
200      sections: [
201        ...(lines.length > 14 ? [{ id: 'tree', title: `Full tree (${lines.length})`, blocks: [{ kind: 'tree' as const, lines }] }] : []),
202        rawSection(text),
203      ],
204    }, [prompt('Expand a subtree', input, `Use ReactComponentTree mode=subtree on ${lines.find(l => l.isOpen === false)?.text ?? 'the collapsed component'} to see its children`)])
205  }
206
207  if (/Component Tree Summary/.test(text) || mode(input, 'summary') === 'summary') {
208    const facts = boldFacts(text)
209    const counts = (section(text, /Instance Count/) ?? '')
210      .split('\n')
211      .map(l => /^\s*-\s+(.+):\s*(\d+)\s*$/.exec(l))
212      .filter((m): m is RegExpExecArray => Boolean(m))
213      .map(m => ({ name: plain(m[1]!), count: Number(m[2]) }))
214    const minified = counts.filter(c => isMangled(c.name)).reduce((a, c) => a + c.count, 0)
215    const named = counts.filter(c => !isMangled(c.name))
216    const items = [...named, ...(minified ? [{ name: 'Anonymous/minified', count: minified }] : [])].sort((a, b) => b.count - a.count)
217    if (!items.length && !facts.length) return genericCard(tool, input, text, 'component-scoped')
218    return card(tool, 'component-scoped', input, text, {
219      summary: `Root: ${fact(facts, /Root/) ?? '?'}`,
220      facts: [
221        { label: 'components', value: fact(facts, /Total components/) ?? '?' },
222        { label: 'max depth', value: fact(facts, /Max depth/) ?? '?' },
223      ],
224      preview: items.length ? [{ kind: 'bars', items: items.slice(0, 8).map(c => ({ label: c.name, value: c.count, display: `×${c.count}`, tone: c.name === 'Anonymous/minified' ? 'muted' : undefined })) }] : [],
225      sections: [
226        { id: 'consumed', title: 'Consumed data', blocks: [{ kind: 'table', columns: ['Component', 'Instances'], rows: counts.map(c => [c.name, String(c.count)]), numeric: [false, true] }] },
227        rawSection(text),
228      ],
229    }, [prompt('Show the tree', input, 'Use ReactComponentTree mode=tree to see the component hierarchy')])
230  }
231  return genericCard(tool, input, text, 'component-scoped')
232}
233
234// ------------------------------------------------- ReactPerformanceInsights
235
236const SEVERITY: Record<string, { tone: Tone; badge: string }> = {
237  '🔴': { tone: 'bad', badge: '●' },
238  '🟠': { tone: 'warn', badge: '●' },
239  '🟡': { tone: 'warn', badge: '●' },
240  '🟢': { tone: 'good', badge: '●' },
241  '🔵': { tone: 'info', badge: '●' },
242}
243
244/** Summary facts and severity-badged findings, each finding's detail in a section. */
245const reactPerformanceInsights: CardParser = (input, text) => {
246  const tool = 'ReactPerformanceInsights'
247  const summary = boldFacts(section(text, /^Summary/) ?? '')
248  const findings = sections(text)
249    .filter(s => s.level >= 3)
250    .map(s => {
251      const raw = text.split('\n').find(l => /^#{3,}\s/.test(l) && plain(l.replace(/^#+\s*/, '')) === s.title) ?? ''
252      const emoji = [...raw].find(ch => ch in SEVERITY) ?? /^(\S+)/.exec(s.title)?.[1] ?? ''
253      const title = s.title.replace(/^[^\p{L}\p{N}]+/u, '').trim()
254      const f = boldFacts(s.body)
255      return { title, emoji, body: s.body.replace(/\n---\s*$/, '').trim(), category: fact(f, /^Category/), impact: fact(f, /^Impact/), point: /point\s+(\d{10,})/.exec(fact(f, /Navigate to/) ?? '')?.[1] }
256    })
257  if (!findings.length && !summary.length) return genericCard(tool, input, text, 'profile-scoped')
258  const top = findings[0]
259  const count = num(/^#+\s*Findings \((\d+)\)/m.exec(text)?.[1]) || findings.length
260  const isDev = /Development build detected/i.test(text)
261  return card(tool, 'profile-scoped', input, text, {
262    summary: `${count} finding${count === 1 ? '' : 's'}${isDev ? ' · development build' : ''}`,
263    status: findings.length ? 'complete' : 'empty',
264    facts: [
265      { label: 'commits', value: fact(summary, /^Commits/) ?? '?' },
266      { label: 'dropped frames', value: fact(summary, /dropped frames/i) ?? '?', tone: num(fact(summary, /dropped frames/i)) > 0 ? 'warn' : 'good' },
267      { label: 'wasted', value: fact(summary, /^Wasted renders/) ?? '?' },
268    ],
269    preview: [{
270      kind: 'list',
271      items: findings.slice(0, 8).map(f => ({ text: f.title, detail: f.impact, tone: SEVERITY[f.emoji]?.tone ?? 'muted', badge: SEVERITY[f.emoji]?.badge ?? '•' })),
272    }],
273    sections: [
274      ...findings.slice(0, 12).map((f, i): Section => ({ id: `f${i}`, title: truncate(f.title, 40), blocks: [{ kind: 'markdown', text: f.body }] })),
275      rawSection(text),
276    ],
277  }, top ? [prompt('Fix the top finding', input, `Use ReactPerformanceInsights findings to fix: "${top.title}"${top.point ? ` (point ${top.point})` : ''}. Find the responsible code and propose a change`)] : [])
278}
279
280// ------------------------------------------------------------ state stores
281
282/**
283 * Redux, Zustand and TanStack Query share a shape: a list mode (actions,
284 * events, queries) as a table, and a detail mode (one entry, state diff) as
285 * facts plus code. Drawn from whatever tables and bold facts the text has.
286 */
287function stateStore(tool: string, entity: string, nextAsk: (firstId: string) => string): CardParser {
288  return (input, text) => {
289    if (notFound(text) || /^\[(Error|InputValidation)\]/.test(text.trim())) return errorCard(tool, input, text)
290    const facts = boldFacts(text)
291    const table = pipeTables(text)[0]
292    const heading = sections(text).find(s => s.level === 1)?.title
293    const showing = /Showing ([^\n]+)/i.exec(text)?.[1]
294    const diff = /```[a-z]*\n([\s\S]*?)```/.exec(text)?.[1]
295    const preview: Block[] = table
296      ? [{ kind: 'table', columns: table.columns, rows: table.rows.slice(0, 10), numeric: table.columns.map(c => /^#$|Index|Time|Count|Observers/i.test(c)) }]
297      : diff
298        ? [{ kind: 'code', lines: diff.split('\n').filter(Boolean).slice(0, 14).map(l => ({ text: l, isMarked: /^\+/.test(l), isUnhit: /^-/.test(l) })) }]
299        : facts.length
300          ? [{ kind: 'facts', items: facts.slice(3, 11) }]
301          : [{ kind: 'markdown', text: truncate(text.trim(), 900) }]
302    const first = table?.rows[0]?.[0]
303    return card(tool, 'recording-wide', input, text, {
304      summary: heading ?? (showing ? plain(showing) : `${entity} in this recording`),
305      facts: facts.slice(0, 3),
306      preview,
307      sections: [
308        ...(table && table.rows.length > 10 ? [{ id: 'all', title: `All ${table.rows.length} ${entity}`, blocks: [{ kind: 'table' as const, columns: table.columns, rows: table.rows }] }] : []),
309        ...(diff && table ? [{ id: 'diff', title: 'State diff', blocks: [{ kind: 'code' as const, lines: diff.split('\n').map(l => ({ text: l })) }] }] : []),
310        rawSection(text),
311      ],
312    }, first ? [prompt(`Inspect ${truncate(first, 16)}`, input, nextAsk(first))] : [])
313  }
314}
315
316// ---------------------------------------------------------------- profiles
317
318/** Flat profile `name (url:line:col): N hits` lines as bars; header facts; continuation point. */
319const profileStatements: CardParser = (input, text) => {
320  const tool = 'ProfileStatements'
321  const head = /Profile from ([\d.]+)ms to ([\d.]+)ms/.exec(text)
322  const total = /Total breakpoint hits:\s*([\d,]+)/.exec(text)?.[1]
323  const unique = /Unique functions:\s*([^\n]+)/.exec(text)?.[1]
324  const cont = /Use (Point:\d+) to continue/.exec(text)?.[1]
325  const flat = (text.split(/^Call tree:/m)[0] ?? text)
326    .split('\n')
327    .map(l => /^\s{2}(\S.*?) \((.+)\): ([\d,]+) hits/.exec(l))
328    .filter((m): m is RegExpExecArray => Boolean(m))
329    .map(m => ({ name: m[1]!, loc: m[2]!, hits: num(m[3]) }))
330  if (!flat.length) return genericCard(tool, input, text, 'profile-scoped')
331  const shortLoc = (loc: string) => loc.replace(/^https?:\/\/[^/]+/, '').replace(/^.*node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?/, '').replace(/\?v=[0-9a-f]+/, '')
332  const appFns = flat.filter(f => !isLibrary(f.loc))
333  return card(tool, 'profile-scoped', input, text, {
334    summary: head ? `Statement hits from ${ms(num(head[1]))} to ${ms(num(head[2]))}` : 'Statement profile',
335    status: cont ? 'partial' : 'complete',
336    facts: [
337      { label: 'hits', value: total ?? '?' },
338      { label: 'functions', value: (unique ?? '?').replace(/\s*\(.*$/, '') },
339      ...(cont ? [{ label: 'continue from', value: cont, tone: 'warn' as const }] : []),
340    ],
341    preview: [{ kind: 'bars', items: flat.slice(0, 8).map(f => ({ label: f.name, value: f.hits, display: `${f.hits.toLocaleString()} · ${truncate(shortLoc(f.loc), 30)}`, tone: isLibrary(f.loc) ? 'muted' : 'accent' })) }],
342    sections: [
343      ...(appFns.length ? [{ id: 'app', title: `App code (${appFns.length})`, blocks: [{ kind: 'bars' as const, items: appFns.slice(0, 15).map(f => ({ label: f.name, value: f.hits, display: `${f.hits.toLocaleString()} · ${truncate(shortLoc(f.loc), 30)}` })) }] }] : []),
344      { id: 'consumed', title: `Consumed data (${flat.length} functions)`, blocks: [{ kind: 'table', columns: ['Function', 'Hits', 'Location'], rows: flat.slice(0, 50).map(f => [f.name, f.hits.toLocaleString(), shortLoc(f.loc)]), numeric: [false, true, false] }] },
345      rawSection(text),
346    ],
347  }, cont ? [prompt(`Continue from ${cont}`, input, `Use ProfileStatements with beginPoint=${cont} to continue profiling the rest of the recording`)] : [])
348}
349
350/** Native frames (C++ symbols, addresses, runtime glue) that hide the JS call tree. */
351const isNative = (frame: string) =>
352  /^_?_Z|^ReplaySpace\+|^0x[0-9a-f]+$|^\(nil\)$|^InvokeOnNewStack$|^_?ChromeMain$|^Chromium\.|^\.\.\. /.test(frame)
353
354/** The call tree with native frames collapsed, plus the JS frame distribution. */
355const profileSampling: CardParser = (input, text) => {
356  const tool = 'ProfileSampling'
357  const head = /Profile from ([\d.]+)ms to ([\d.]+)ms/.exec(text)
358  const samples = /Total samples:\s*([\d,]+)/.exec(text)?.[1]
359  const [treePart = '', distPart = ''] = (text.split(/^Call tree:/m)[1] ?? '').split(/^JS frame distribution:/m)
360  const nodes = treePart
361    .split('\n')
362    .map(l => /^(\s*)([\d.]+)ms: (.+)$/.exec(l))
363    .filter((m): m is RegExpExecArray => Boolean(m))
364    .map(m => ({ indent: m[1]!.length, ms: num(m[2]), frame: m[3]!.trim() }))
365  // Keep JS frames; their depth is how many kept frames enclose them.
366  const kept: { depth: number; text: string; detail: string }[] = []
367  const stack: number[] = []
368  for (const n of nodes) {
369    if (isNative(n.frame)) continue
370    while (stack.length && stack[stack.length - 1]! >= n.indent) stack.pop()
371    kept.push({ depth: stack.length, text: truncate(n.frame.replace(/\s*\(https?:\/\/[^)]*\)/, ''), 60), detail: ms(n.ms) })
372    stack.push(n.indent)
373  }
374  const dist = distPart
375    .split('\n')
376    .map(l => /^\s*([\d.]+)ms: (.+)$/.exec(l))
377    .filter((m): m is RegExpExecArray => Boolean(m))
378    .map(m => ({ ms: num(m[1]), name: m[2]!.replace(/\s*\(https?:\/\/[^)]*\)/, ''), isLib: isLibrary(m[2]!) }))
379  if (!nodes.length && !dist.length) return genericCard(tool, input, text, 'profile-scoped')
380  const native = nodes.filter(n => isNative(n.frame)).length
381  return card(tool, 'profile-scoped', input, text, {
382    summary: head ? `Sampled ${ms(num(head[1]))}–${ms(num(head[2]))}` : 'Sampled CPU profile',
383    facts: [
384      { label: 'samples', value: samples ?? '?' },
385      { label: 'JS frames', value: String(kept.length) },
386      { label: 'native frames hidden', value: String(native), tone: 'muted' },
387    ],
388    preview: dist.length
389      ? [{ kind: 'bars', items: dist.slice(0, 8).map(d => ({ label: d.name, value: d.ms, display: ms(d.ms), tone: d.isLib ? 'muted' : 'accent' })) }]
390      : kept.length
391        ? [{ kind: 'tree', lines: kept.slice(0, 12) }]
392        : [{ kind: 'text', text: 'Only native frames above the sampling threshold.', tone: 'muted' }],
393    sections: [
394      ...(kept.length ? [{ id: 'tree', title: `Call tree (${kept.length} JS frames)`, blocks: [{ kind: 'tree' as const, lines: kept.slice(0, 80) }] }] : []),
395      rawSection(text),
396    ],
397  }, [prompt('Profile statements', input, 'Use ProfileStatements over the hottest range to see which statements ran most')])
398}
399
400/** Top-level sources with their share as bars; nested ReactRender entries per source in sections. */
401const profileGraph: CardParser = (input, text) => {
402  const tool = 'ProfileGraph'
403  const head = /Graph profile from ([\d.]+)ms to ([\d.]+)ms/.exec(text)
404  const total = /Total execution:\s*([^\n]+)/.exec(text)?.[1]
405  const entry = /^(\s*)(.+?)(?: x (\d+))? @ ([\d.]+)ms(?: point:(\d+))?: (\d+) \(([\d.]+)%\)\s*$/
406  const tops: { name: string; at: string; units: number; pct: number; children: { name: string; count?: string; units: number; pct: number; point?: string }[] }[] = []
407  for (const line of text.split('\n')) {
408    const m = entry.exec(line)
409    if (!m) continue
410    if (m[1]!.length === 0) tops.push({ name: m[2]!, at: m[4]!, units: num(m[6]), pct: num(m[7]), children: [] })
411    else tops[tops.length - 1]?.children.push({ name: m[2]!.trim(), count: m[3], units: num(m[6]), pct: num(m[7]), point: m[5] })
412  }
413  if (!tops.length) return genericCard(tool, input, text, 'profile-scoped')
414  const withChildren = tops.filter(t => t.children.length)
415  return card(tool, 'profile-scoped', input, text, {
416    summary: head ? `Execution ${ms(num(head[1]))}–${ms(num(head[2]))}` : 'Execution graph',
417    facts: [
418      { label: 'duration', value: head ? ms(num(head[2]) - num(head[1])) : '?' },
419      { label: 'execution', value: (total ?? '?').replace(/ progress units.*/, ' units') },
420    ],
421    preview: [{ kind: 'bars', items: tops.slice(0, 8).map(t => ({ label: t.name, value: t.pct, display: `${t.pct}% @ ${ms(num(t.at))}` })) }],
422    sections: [
423      ...withChildren.slice(0, 4).map((t, i): Section => ({
424        id: `src${i}`,
425        title: truncate(t.name, 32),
426        blocks: [{ kind: 'bars', items: t.children.map(c => ({ label: `${c.name}${c.count ? ` ×${c.count}` : ''}`, value: c.pct, display: `${c.pct}%`, tone: /^ReactRender/.test(c.name) ? 'info' : undefined })) }],
427      })),
428      rawSection(text),
429    ],
430  }, withChildren[0]?.children[0]?.point
431    ? [prompt(`Inspect ${truncate(withChildren[0].children[0].name, 18)}`, input, `Use DescribePoint on point ${withChildren[0].children[0].point} (${withChildren[0].children[0].name}) to see what it did`)]
432    : [])
433}
434
435export const REACT_PARSERS: Record<string, CardParser> = {
436  ReactRenders: reactRenders,
437  ReactComponentTree: reactComponentTree,
438  ReactPerformanceInsights: reactPerformanceInsights,
439  ReduxActions: stateStore('ReduxActions', 'actions', id => `Use ReduxActions mode=action&actionIndex=${id} to see that action's payload and state diff`),
440  ZustandStores: stateStore('ZustandStores', 'stores', id => `Use ZustandStores with storeId=${id} to see that store's mutations`),
441  TanStackQueries: stateStore('TanStackQueries', 'queries', id => `Use TanStackQueries with queryHash=${id} to see that query's lifecycle`),
442  ProfileStatements: profileStatements,
443  ProfileSampling: profileSampling,
444  ProfileGraph: profileGraph,
445}
446
447