aipeek 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,689 @@
1
+ /// <reference types="vite/client" />
2
+ // aipeek client — collectors + HMR channel (module)
3
+ // The patch code (console/fetch/XHR/errors) is in client-patch.ts,
4
+ // injected as a synchronous inline script before any modules execute.
5
+
6
+ import type { ActionArgs, ActionType } from '../core/action'
7
+ import type { ErrorEntry, LogEntry, NetworkRequest } from '../core/types'
8
+ import { INTERACTIVE, performAction } from '../core/action'
9
+
10
+ declare global {
11
+ interface Window { __AIPEEK_STORES__?: Record<string, unknown> }
12
+ }
13
+
14
+ interface AipeekBuffers {
15
+ consoleLogs: LogEntry[]
16
+ networkRequests: NetworkRequest[]
17
+ errorEntries: ErrorEntry[]
18
+ }
19
+
20
+ // Read buffers from the synchronous patch script
21
+ const buffers = (window as any).__AIPEEK_BUFFERS__ as AipeekBuffers | undefined
22
+ const consoleLogs: LogEntry[] = buffers?.consoleLogs ?? []
23
+ const networkRequests: NetworkRequest[] = buffers?.networkRequests ?? []
24
+ const errorEntries: ErrorEntry[] = buffers?.errorEntries ?? []
25
+
26
+ // --- On-demand collectors ---
27
+
28
+ const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'link', 'meta', 'head'])
29
+
30
+ const IMPLICIT_ROLES: Record<string, string> = {
31
+ a: 'link',
32
+ button: 'button',
33
+ input: 'textbox',
34
+ textarea: 'textbox',
35
+ select: 'combobox',
36
+ img: 'img',
37
+ nav: 'navigation',
38
+ main: 'main',
39
+ header: 'banner',
40
+ footer: 'contentinfo',
41
+ aside: 'complementary',
42
+ form: 'form',
43
+ dialog: 'dialog',
44
+ ul: 'list',
45
+ ol: 'list',
46
+ li: 'listitem',
47
+ table: 'table',
48
+ h1: 'heading',
49
+ h2: 'heading',
50
+ h3: 'heading',
51
+ h4: 'heading',
52
+ h5: 'heading',
53
+ h6: 'heading',
54
+ }
55
+
56
+ function collectUI(): string {
57
+ const root = document.getElementById('root')
58
+ if (!root)
59
+ return collectDomFallback()
60
+
61
+ const fiberKey = Object.keys(root).find(k =>
62
+ k.startsWith('__reactContainer$') || k.startsWith('__reactFiber$'),
63
+ )
64
+ if (!fiberKey)
65
+ return collectDomFallback()
66
+
67
+ const rootFiber = (root as any)[fiberKey]
68
+ if (!rootFiber)
69
+ return collectDomFallback()
70
+
71
+ const COMPONENT_TAGS = new Set([0, 1, 14, 15])
72
+ const SKIP_EXACT = new Set([
73
+ 'Fragment',
74
+ 'Suspense',
75
+ 'StrictMode',
76
+ 'Context',
77
+ 'Outlet',
78
+ 'Routes',
79
+ 'RenderedRoute',
80
+ 'DataRoutes',
81
+ 'RemoveScrollBar',
82
+ 'Sheet',
83
+ 'Router',
84
+ 'RenderErrorBoundary',
85
+ 'Tooltip',
86
+ 'Popover',
87
+ 'Dialog',
88
+ 'DropdownMenu',
89
+ 'ContextMenu',
90
+ 'HoverCard',
91
+ 'Select',
92
+ 'Command',
93
+ 'Tabs',
94
+ 'RadioGroup',
95
+ 'Markdown',
96
+ 'Sonner',
97
+ ])
98
+ // wrapper components carry no UI semantics — skip by name shape
99
+ const SKIP_CONTAINS = ['Provider', 'Consumer', 'Portal', 'Popper', 'Presence', 'SideCar']
100
+ const SKIP_ENDS = ['Warning', 'Header', 'Footer', 'Menu', 'Root']
101
+ const isSkipped = (name: string) =>
102
+ SKIP_CONTAINS.some(s => name.includes(s)) || SKIP_ENDS.some(s => name.endsWith(s))
103
+ const lines: string[] = []
104
+
105
+ // strip a trailing run of digits (React dedup suffix: Foo2 → Foo)
106
+ function cleanName(name: string): string {
107
+ let end = name.length
108
+ while (end > 0 && name[end - 1] >= '0' && name[end - 1] <= '9') end--
109
+ return name.slice(0, end)
110
+ }
111
+
112
+ function getDomDesc(fiber: any): string {
113
+ const el = fiber.stateNode
114
+ if (!(el instanceof Element))
115
+ return ''
116
+
117
+ const parts: string[] = []
118
+ const tag = el.tagName.toLowerCase()
119
+ const role = el.getAttribute('role') || IMPLICIT_ROLES[tag] || ''
120
+ if (role)
121
+ parts.push(role)
122
+
123
+ const ariaLabel = el.getAttribute('aria-label') || ''
124
+ const text = ariaLabel || getDirectText(el)
125
+ if (text)
126
+ parts.push(`"${text.slice(0, 80)}"`)
127
+
128
+ for (const s of baseState(el)) parts.push(`[${s}]`)
129
+
130
+ return parts.join(' ')
131
+ }
132
+
133
+ function getComponentDomDesc(fiber: any): string {
134
+ let node = fiber.child
135
+ while (node) {
136
+ if (node.tag === 5)
137
+ return getDomDesc(node)
138
+ if (node.tag === 6) {
139
+ const text = node.memoizedProps
140
+ if (typeof text === 'string' && text.trim())
141
+ return `"${text.trim().slice(0, 80)}"`
142
+ }
143
+ node = node.child
144
+ }
145
+ return ''
146
+ }
147
+
148
+ function walkFiber(fiber: any, depth: number) {
149
+ if (!fiber || depth > 40)
150
+ return
151
+
152
+ const isComponent = COMPONENT_TAGS.has(fiber.tag)
153
+ const rawName = fiber.type?.displayName || fiber.type?.name
154
+
155
+ if (isComponent && rawName && rawName.length > 1 && rawName[0] !== '_'
156
+ && !SKIP_EXACT.has(rawName) && !isSkipped(rawName)) {
157
+ const name = cleanName(rawName)
158
+ const indent = ' '.repeat(depth)
159
+ let desc = name
160
+
161
+ const domDesc = getComponentDomDesc(fiber)
162
+ if (domDesc)
163
+ desc += ` — ${domDesc}`
164
+
165
+ const props = fiber.memoizedProps
166
+ if (props?.generating)
167
+ desc += ' [generating]'
168
+ if (props?.loading)
169
+ desc += ' [loading]'
170
+
171
+ lines.push(`${indent}${desc}`)
172
+ walkChildren(fiber, depth + 1)
173
+ }
174
+ else {
175
+ walkChildren(fiber, depth)
176
+ }
177
+ }
178
+
179
+ function walkChildren(fiber: any, depth: number) {
180
+ let child = fiber.child
181
+ while (child) {
182
+ walkFiber(child, depth)
183
+ child = child.sibling
184
+ }
185
+ }
186
+
187
+ walkFiber(rootFiber, 0)
188
+ if (!lines.length)
189
+ return collectDomFallback()
190
+
191
+ const minIndent = Math.min(...lines.map(l => l.length - l.trimStart().length))
192
+ if (minIndent > 0)
193
+ return lines.map(l => l.slice(minIndent)).join('\n')
194
+ return lines.join('\n')
195
+ }
196
+
197
+ function collectDomFallback(): string {
198
+ const lines: string[] = []
199
+
200
+ function walk(el: Element, depth: number) {
201
+ if (depth > 12)
202
+ return
203
+ const tag = el.tagName.toLowerCase()
204
+ if (SKIP_TAGS.has(tag))
205
+ return
206
+ if (tag === 'svg') {
207
+ lines.push(`${' '.repeat(depth)}img: [svg]`)
208
+ return
209
+ }
210
+
211
+ const role = el.getAttribute('role') || IMPLICIT_ROLES[tag] || ''
212
+ const ariaLabel = el.getAttribute('aria-label') || ''
213
+ const directText = getDirectText(el)
214
+
215
+ let desc = role || tag
216
+ if (ariaLabel)
217
+ desc += `: ${ariaLabel}`
218
+ else if (directText)
219
+ desc += `: ${directText.slice(0, 80)}`
220
+
221
+ for (const s of baseState(el)) desc += ` [${s}]`
222
+
223
+ lines.push(' '.repeat(depth) + desc)
224
+ for (const child of el.children) walk(child, depth + 1)
225
+ }
226
+
227
+ walk(document.body, 0)
228
+ return lines.join('\n')
229
+ }
230
+
231
+ function getDirectText(el: Element): string {
232
+ let text = ''
233
+ for (const node of el.childNodes) {
234
+ if (node.nodeType === Node.TEXT_NODE) {
235
+ const t = node.textContent?.trim()
236
+ if (t)
237
+ text += (text ? ' ' : '') + t
238
+ }
239
+ }
240
+ return text
241
+ }
242
+
243
+ // --- Semantic DOM collector ---
244
+ // UI-as-text: strip Tailwind atomic classes, keep only what carries meaning —
245
+ // tag, role, semantic classes, data-*/aria, text, interactive state, a usable selector.
246
+
247
+ // Tailwind atomic-class prefixes. A class is "noise" if it starts with one of
248
+ // these (with - or end), or contains a variant separator (hover:, md:, dark:).
249
+ const TW_PREFIXES = new Set([
250
+ 'flex',
251
+ 'grid',
252
+ 'block',
253
+ 'inline',
254
+ 'hidden',
255
+ 'table',
256
+ 'contents',
257
+ 'p',
258
+ 'px',
259
+ 'py',
260
+ 'pt',
261
+ 'pb',
262
+ 'pl',
263
+ 'pr',
264
+ 'm',
265
+ 'mx',
266
+ 'my',
267
+ 'mt',
268
+ 'mb',
269
+ 'ml',
270
+ 'mr',
271
+ 'w',
272
+ 'h',
273
+ 'min',
274
+ 'max',
275
+ 'size',
276
+ 'gap',
277
+ 'space',
278
+ 'text',
279
+ 'font',
280
+ 'leading',
281
+ 'tracking',
282
+ 'whitespace',
283
+ 'truncate',
284
+ 'break',
285
+ 'bg',
286
+ 'border',
287
+ 'rounded',
288
+ 'ring',
289
+ 'shadow',
290
+ 'outline',
291
+ 'divide',
292
+ 'opacity',
293
+ 'items',
294
+ 'justify',
295
+ 'self',
296
+ 'place',
297
+ 'content',
298
+ 'order',
299
+ 'col',
300
+ 'row',
301
+ 'absolute',
302
+ 'relative',
303
+ 'fixed',
304
+ 'sticky',
305
+ 'static',
306
+ 'top',
307
+ 'bottom',
308
+ 'left',
309
+ 'right',
310
+ 'z',
311
+ 'inset',
312
+ 'overflow',
313
+ 'overscroll',
314
+ 'cursor',
315
+ 'select',
316
+ 'pointer',
317
+ 'resize',
318
+ 'scroll',
319
+ 'transition',
320
+ 'duration',
321
+ 'ease',
322
+ 'delay',
323
+ 'animate',
324
+ 'transform',
325
+ 'translate',
326
+ 'rotate',
327
+ 'scale',
328
+ 'skew',
329
+ 'origin',
330
+ 'fill',
331
+ 'stroke',
332
+ 'object',
333
+ 'aspect',
334
+ 'basis',
335
+ 'shrink',
336
+ 'grow',
337
+ 'flex',
338
+ 'antialiased',
339
+ 'uppercase',
340
+ 'lowercase',
341
+ 'capitalize',
342
+ 'italic',
343
+ 'underline',
344
+ 'line',
345
+ 'list',
346
+ 'align',
347
+ 'backdrop',
348
+ 'blur',
349
+ 'brightness',
350
+ 'contrast',
351
+ 'saturate',
352
+ 'invert',
353
+ 'sepia',
354
+ 'grayscale',
355
+ ])
356
+
357
+ function isNoiseClass(cls: string): boolean {
358
+ if (cls.includes(':')) // variant: hover:, md:, dark:, group-hover:
359
+ return true
360
+ if (cls[0] === '[') // arbitrary value: [mask-type:luminance]
361
+ return true
362
+ const head = cls.split('-')[0]
363
+ return TW_PREFIXES.has(head)
364
+ }
365
+
366
+ export function semanticClasses(el: Element): string {
367
+ const cls = el.getAttribute('class')
368
+ if (!cls)
369
+ return ''
370
+ return cls.split(' ').map(c => c.trim()).filter(c => c && !isNoiseClass(c)).slice(0, 6).join('.')
371
+ }
372
+
373
+ // code-inspector data-insp-path looks like "src/features/Sidebar/index.tsx:78:17:App"
374
+ // → compact to "Sidebar/index.tsx:78" (last dir + file + line)
375
+ export function inspPath(el: Element): string {
376
+ const raw = el.getAttribute('data-insp-path')
377
+ if (!raw)
378
+ return ''
379
+ const segs = raw.split(':')
380
+ const file = segs[0]
381
+ const line = segs[1]
382
+ const fileParts = file.split('/')
383
+ const tail = fileParts.slice(-2).join('/')
384
+ return line ? `${tail}:${line}` : tail
385
+ }
386
+
387
+ // focused/disabled/expanded — the three states shared by every DOM describer
388
+ function baseState(el: Element): string[] {
389
+ const parts: string[] = []
390
+ if (document.activeElement === el)
391
+ parts.push('focused')
392
+ if ((el as HTMLButtonElement).disabled)
393
+ parts.push('disabled')
394
+ if (el.getAttribute('aria-expanded') === 'true')
395
+ parts.push('expanded')
396
+ return parts
397
+ }
398
+
399
+ function domState(el: Element): string {
400
+ const parts = baseState(el)
401
+ if (el.getAttribute('aria-selected') === 'true' || el.getAttribute('aria-checked') === 'true')
402
+ parts.push('selected')
403
+ if ((el as HTMLInputElement).value)
404
+ parts.push(`value="${(el as HTMLInputElement).value.slice(0, 40)}"`)
405
+ if (el.hasAttribute('contenteditable'))
406
+ parts.push('editable')
407
+ return parts.length ? ` {${parts.join(' ')}}` : ''
408
+ }
409
+
410
+ // Find the scope root: a CSS selector, or a component name matched against the
411
+ // code-inspector data-insp-path. Returns body when no scope given, null on miss.
412
+ function scopeRoot(scope?: string, sel?: string): Element | null {
413
+ if (sel)
414
+ return document.querySelector(sel)
415
+ if (scope) {
416
+ const all = document.querySelectorAll('[data-insp-path]')
417
+ const lower = scope.toLowerCase()
418
+ for (const el of all) {
419
+ if ((el.getAttribute('data-insp-path') || '').toLowerCase().includes(lower))
420
+ return el
421
+ }
422
+ return null
423
+ }
424
+ return document.body
425
+ }
426
+
427
+ export function collectDom(scope?: string, sel?: string): string {
428
+ const root = scopeRoot(scope, sel)
429
+ if (!root)
430
+ return `(no element for scope="${scope || sel}")`
431
+ const lines: string[] = []
432
+
433
+ function walk(el: Element, depth: number) {
434
+ if (depth > 20)
435
+ return
436
+ const tag = el.tagName.toLowerCase()
437
+ if (SKIP_TAGS.has(tag))
438
+ return
439
+ if (tag === 'svg') {
440
+ if (el.getBoundingClientRect().width > 0)
441
+ lines.push(`${' '.repeat(depth)}svg [icon]`)
442
+ return
443
+ }
444
+
445
+ const role = el.getAttribute('role') || IMPLICIT_ROLES[tag] || ''
446
+ const sem = semanticClasses(el)
447
+ // data-* minus framework noise. data-insp-path (code-inspector) is the source
448
+ // location — keep it, but compacted to its file:line tail, shown only when the
449
+ // node is itself meaningful (carries role/text/state), not on bare wrappers.
450
+ const dataAttrs = Array.from(el.attributes)
451
+ .filter(a => a.name.startsWith('data-') && a.name !== 'data-testid' && a.name !== 'data-insp-path')
452
+ .map(a => a.value ? `${a.name}="${a.value.slice(0, 30)}"` : a.name)
453
+ .slice(0, 3)
454
+ const aria = el.getAttribute('aria-label') || ''
455
+ const text = aria || getDirectText(el)
456
+ const state = domState(el)
457
+
458
+ // collapse an element whose only child is an icon → one line
459
+ const onlyIcon = el.children.length === 1 && el.children[0].tagName.toLowerCase() === 'svg'
460
+
461
+ // a node is meaningful if it carries semantics a layout div doesn't
462
+ const meaningful = !!(role || sem || dataAttrs.length || text || el.id || state || el.hasAttribute('contenteditable'))
463
+
464
+ if (meaningful) {
465
+ const parts = [role ? `${tag}[${role}]` : tag]
466
+ if (sem)
467
+ parts.push(`.${sem}`)
468
+ if (el.id)
469
+ parts.push(`#${el.id}`)
470
+ for (const d of dataAttrs)
471
+ parts.push(`[${d}]`)
472
+ if (text)
473
+ parts.push(`"${text.slice(0, 60)}"`)
474
+ if (onlyIcon)
475
+ parts.push('[icon]')
476
+ const src = inspPath(el)
477
+ lines.push(' '.repeat(depth) + parts.join(' ') + state + (src ? ` @${src}` : ''))
478
+ depth += 1
479
+ }
480
+
481
+ if (!onlyIcon) {
482
+ for (const child of el.children) walk(child, depth)
483
+ }
484
+ }
485
+
486
+ walk(root, 0)
487
+ return lines.join('\n')
488
+ }
489
+
490
+ function collectState(): Record<string, unknown> {
491
+ const stores = window.__AIPEEK_STORES__
492
+ if (!stores)
493
+ return {}
494
+
495
+ const result: Record<string, unknown> = {}
496
+ for (const [name, store] of Object.entries(stores)) {
497
+ try {
498
+ result[name] = boundedSnapshot(store)
499
+ }
500
+ catch {
501
+ result[name] = '[error]'
502
+ }
503
+ }
504
+ return result
505
+ }
506
+
507
+ // State-machine projection: collapse the whole UI to {view, modal, focus, knobs}
508
+ // — the few variables a human reads off a washing-machine panel to act in O(1).
509
+ // knobs are clipped to what's reachable *now*: when a modal is open, only its
510
+ // subtree counts (the layer beneath is unclickable).
511
+ export function collectScreen(): string {
512
+ const stores = window.__AIPEEK_STORES__ as Record<string, any> | undefined
513
+ const view = stores?.appUIStore?.mode ?? '(unknown)'
514
+
515
+ const modalEl = document.querySelector('[role="dialog"][data-state="open"]')
516
+ const modalTitle = modalEl
517
+ ? (modalEl.querySelector('h1, h2, [id^="radix"]')?.textContent || '').trim().slice(0, 40)
518
+ : ''
519
+ const modal = modalEl ? `${modalTitle || 'untitled'} @${inspPath(modalEl) || 'dialog'}` : 'none'
520
+
521
+ // Icon buttons carry no direct text — fall back to title, then any nested text.
522
+ const labelOf = (el: Element) => (el.getAttribute('aria-label') || getDirectText(el) || el.getAttribute('title') || (el.textContent || '').trim()).trim()
523
+ const visible = (el: Element) => el.getBoundingClientRect().width > 0
524
+
525
+ const active = document.activeElement
526
+ const focus = active && active !== document.body
527
+ ? `${labelOf(active).slice(0, 30) || active.tagName.toLowerCase()} [${active.tagName.toLowerCase()}]${domState(active)}`
528
+ : 'none'
529
+
530
+ // Group by source location. A source rendered many times is a *list* (content),
531
+ // not a control — collapse it to one "source ×N" line. The washing-machine
532
+ // panel shows "the laundry", not every garment.
533
+ const root = modalEl ?? document.body
534
+ const bySource = new Map<string, Element[]>()
535
+ for (const el of root.querySelectorAll(INTERACTIVE)) {
536
+ if (!visible(el))
537
+ continue
538
+ const loc = inspPath(el) || '?'
539
+ if (!bySource.has(loc))
540
+ bySource.set(loc, [])
541
+ bySource.get(loc)!.push(el)
542
+ }
543
+ const knobs: string[] = []
544
+ for (const [loc, els] of bySource) {
545
+ if (els.length > 2) {
546
+ knobs.push(` ${loc} ×${els.length} (list)`)
547
+ continue
548
+ }
549
+ for (const el of els) {
550
+ const label = labelOf(el).slice(0, 40)
551
+ knobs.push(` ${label || `<${el.tagName.toLowerCase()}>`} [${el.tagName.toLowerCase()}]${loc !== '?' ? ` ${loc}` : ''}${domState(el)}`)
552
+ }
553
+ if (knobs.length >= 40)
554
+ break
555
+ }
556
+
557
+ return [
558
+ `view: ${view}`,
559
+ `modal: ${modal}`,
560
+ `focus: ${focus}`,
561
+ `knobs (${knobs.length}):`,
562
+ ...knobs,
563
+ ].join('\n')
564
+ }
565
+
566
+ function boundedSnapshot(obj: unknown, depth = 0): unknown {
567
+ if (depth > 3)
568
+ return '[…]'
569
+ if (obj === null || obj === undefined)
570
+ return obj
571
+ if (typeof obj === 'function')
572
+ return undefined
573
+ if (typeof obj === 'string')
574
+ return obj.length > 100 ? `${obj.slice(0, 100)}…` : obj
575
+ if (typeof obj !== 'object')
576
+ return obj
577
+ if (Array.isArray(obj))
578
+ return `Array(${obj.length})`
579
+
580
+ const result: Record<string, unknown> = {}
581
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>).slice(0, 20)) {
582
+ if (typeof v === 'function')
583
+ continue
584
+ result[k] = boundedSnapshot(v, depth + 1)
585
+ }
586
+ return result
587
+ }
588
+
589
+ // --- HMR channel ---
590
+
591
+ // Resolve once the DOM goes quiet (no mutations for `quiet`ms) or `timeout` hits,
592
+ // then return the fresh UI tree. Lets a click/fill report its own outcome.
593
+ function waitForStable(quiet = 150, timeout = 2000): Promise<string> {
594
+ return new Promise((resolve) => {
595
+ const start = performance.now()
596
+ let timer = 0
597
+ let observer: MutationObserver
598
+ const done = () => {
599
+ observer.disconnect()
600
+ clearTimeout(timer)
601
+ resolve(collectUI())
602
+ }
603
+ observer = new MutationObserver(() => {
604
+ clearTimeout(timer)
605
+ if (performance.now() - start > timeout)
606
+ done()
607
+ else
608
+ timer = window.setTimeout(done, quiet)
609
+ })
610
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true })
611
+ timer = window.setTimeout(done, quiet)
612
+ })
613
+ }
614
+
615
+ // Multi-tab: the HMR channel broadcasts to every open tab. Only the tab the user
616
+ // is actually looking at should answer — otherwise server races N responses and
617
+ // keeps a random one, while click/fill fire in *all* tabs. visibilityState is
618
+ // 'visible' for exactly the focused tab (or single tab), 'hidden' for the rest.
619
+ //
620
+ // But when the user is reading the terminal, EVERY dev tab is hidden — then no one
621
+ // would answer. So the server sends requireVisible=true first; if it times out with
622
+ // no answer it retries requireVisible=false, and every tab answers (the original
623
+ // race). `skip` collapses that into one guard: hidden tab skips only round one.
624
+ function skip(msg?: { requireVisible?: boolean }) {
625
+ return msg?.requireVisible !== false && document.visibilityState !== 'visible'
626
+ }
627
+
628
+ if (import.meta.hot) {
629
+ import.meta.hot.on('aipeek:collect', (msg: { requireVisible?: boolean }) => {
630
+ if (skip(msg))
631
+ return
632
+ import.meta.hot!.send('aipeek:state', {
633
+ url: location.href,
634
+ ui: collectUI(),
635
+ console: [...consoleLogs],
636
+ network: [...networkRequests],
637
+ errors: [...errorEntries],
638
+ state: collectState(),
639
+ timestamp: Date.now(),
640
+ })
641
+ })
642
+
643
+ import.meta.hot.on('aipeek:action', async (msg: { id: number, type: ActionType, args: ActionArgs, requireVisible?: boolean }) => {
644
+ if (skip(msg))
645
+ return
646
+ const result = await performAction(msg.type, msg.args)
647
+ // For mutating actions, settle the DOM then ship both the full UI tree and
648
+ // the compact screen projection — the caller skips a round-trip to /ui, and
649
+ // /chain uses the per-step screen so an interaction's every transition shows.
650
+ if (result.ok && (msg.type === 'click' || msg.type === 'fill' || msg.type === 'press')) {
651
+ result.ui = await waitForStable()
652
+ result.screen = collectScreen()
653
+ }
654
+ import.meta.hot!.send('aipeek:result', { id: msg.id, ...result })
655
+ })
656
+
657
+ // eval: run server-supplied code in the page. Wrapped in an async IIFE so the
658
+ // code can `await` and use `return`; non-string results are JSON-stringified.
659
+ import.meta.hot.on('aipeek:eval', async (msg: { id: number, code: string, requireVisible?: boolean }) => {
660
+ if (skip(msg))
661
+ return
662
+ let ok = true
663
+ let value: string | undefined
664
+ let error: string | undefined
665
+ try {
666
+ // eslint-disable-next-line no-new-func
667
+ const fn = new Function(`return (async () => { ${msg.code} })()`)
668
+ const result = await fn()
669
+ value = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
670
+ }
671
+ catch (e) {
672
+ ok = false
673
+ error = e instanceof Error ? `${e.message}\n${e.stack ?? ''}` : String(e)
674
+ }
675
+ import.meta.hot!.send('aipeek:eval-result', { id: msg.id, ok, value, error })
676
+ })
677
+
678
+ import.meta.hot.on('aipeek:collect-dom', (msg: { scope?: string, sel?: string, requireVisible?: boolean }) => {
679
+ if (skip(msg))
680
+ return
681
+ import.meta.hot!.send('aipeek:dom', { dom: collectDom(msg?.scope, msg?.sel) })
682
+ })
683
+
684
+ import.meta.hot.on('aipeek:collect-screen', (msg: { requireVisible?: boolean }) => {
685
+ if (skip(msg))
686
+ return
687
+ import.meta.hot!.send('aipeek:screen', { screen: collectScreen() })
688
+ })
689
+ }