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

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
| Item | Installs |
|---|---|
codex | Codex 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-pro | Codex 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. |
cursor | Cursor plugin bundle installed to .cursor-plugin/, skills/, assets/, hooks, scripts, MCP config, and worker/critic proof-loop prompts. |
claude-code | Claude Code Replay QA plugin installed to .claude/skills/replay-qa/, including plugin commands and script-backed skill helpers. |
claude-pro | Claude Code Replay.io Pro plugin installed to .claude/skills/replayio/, .claude/agents/, hooks, scripts, MCP config, commands, and Replay Pro skill guidance. |
opencode | OpenCode plugin bundle installed to .opencode/plugins/, .opencode/agent/, AGENTS.md, and opencode.json, including worker/critic proof-loop subagents. |
obvious-ai | Skills-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 the registry before publishing changes:
npx shadcn@latest registry validate registry.jsonhooks/register.tsx 488 lines1/* @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}
488hooks/detect.ts 206 lines1// 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}
206hooks/mcp/render.tsx 293 lines1/* @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}
293hooks/scrubber.tsx 88 lines1/* @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}
88hooks/mcp/card.ts 248 lines1// 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}
248hooks/mcp/cards.ts 39 lines1// 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}
39hooks/mcp/tools/index.ts 12 lines1import 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}
12hooks/mcp/tools/code.ts 564 lines1// 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}
564hooks/mcp/tools/overview.ts 426 lines1import {
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}
426hooks/mcp/tools/react.ts 447 lines1// 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