aipeek 0.2.6 → 0.2.8

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.
@@ -6,7 +6,8 @@
6
6
  // - performAction(): real DOM mutation. Runs browser-side (client.ts imports it).
7
7
  // References window/document — never imported plugin-side.
8
8
 
9
- export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot'
9
+ export type ActionType = 'click' | 'fill' | 'press' | 'wait' | 'screenshot' | 'realclick' | 'query' | 'assert'
10
+ | 'drag' | 'scrollIntoView' | 'drop' | 'clipboard'
10
11
 
11
12
  export interface ActionArgs {
12
13
  sel?: string
@@ -15,6 +16,17 @@ export interface ActionArgs {
15
16
  key?: string
16
17
  timeout?: number
17
18
  gone?: boolean
19
+ button?: 'left' | 'right'
20
+ x?: number
21
+ y?: number
22
+ // assert: a domain key (from __AIPEEK_SCREEN__) or a CSS sel, checked against `equals`.
23
+ screen?: string
24
+ equals?: string
25
+ // drag: sel/text = source, to = destination selector. drop: sel = target, files = names.
26
+ to?: string
27
+ files?: string[]
28
+ // clipboard: mode read|write; value = text to write (write only).
29
+ mode?: 'read' | 'write'
18
30
  }
19
31
 
20
32
  export interface ActionResult {
@@ -22,11 +34,16 @@ export interface ActionResult {
22
34
  detail?: string
23
35
  error?: string
24
36
  dataUrl?: string
25
- ui?: string
26
37
  screen?: string
38
+ actions?: string
39
+ x?: number
40
+ y?: number
41
+ // realclick only: true if the page already fired the trusted click in-process (Electron).
42
+ // false/undefined means a plain Chrome tab couldn't, so the server drives its CDP queue.
43
+ fired?: boolean
27
44
  }
28
45
 
29
- const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot']
46
+ const TYPES: ActionType[] = ['click', 'fill', 'press', 'wait', 'screenshot', 'realclick', 'query', 'assert', 'drag', 'scrollIntoView', 'drop', 'clipboard']
30
47
 
31
48
  // --- Pure validation (plugin-side) ---
32
49
 
@@ -38,6 +55,8 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
38
55
  switch (type) {
39
56
  case 'click':
40
57
  return hasTarget ? { valid: true } : { valid: false, error: 'click needs sel= or text=' }
58
+ case 'realclick':
59
+ return hasTarget || (args.x !== undefined && args.y !== undefined) ? { valid: true } : { valid: false, error: 'realclick needs sel=, text=, or x= & y=' }
41
60
  case 'fill':
42
61
  if (!hasTarget)
43
62
  return { valid: false, error: 'fill needs sel= or text=' }
@@ -50,6 +69,32 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
50
69
  return hasTarget ? { valid: true } : { valid: false, error: 'wait needs sel= or text=' }
51
70
  case 'screenshot':
52
71
  return { valid: true }
72
+ case 'query':
73
+ return args.sel ? { valid: true } : { valid: false, error: 'query needs sel=' }
74
+ case 'assert':
75
+ if (!args.screen && !args.sel)
76
+ return { valid: false, error: 'assert needs screen= (a __AIPEEK_SCREEN__ key) or sel=' }
77
+ if (args.equals === undefined)
78
+ return { valid: false, error: 'assert needs equals=' }
79
+ return { valid: true }
80
+ case 'drag':
81
+ if (!hasTarget)
82
+ return { valid: false, error: 'drag needs sel= or text= (the source)' }
83
+ if (!args.to)
84
+ return { valid: false, error: 'drag needs to= (destination selector)' }
85
+ return { valid: true }
86
+ case 'scrollIntoView':
87
+ return hasTarget ? { valid: true } : { valid: false, error: 'scrollIntoView needs sel= or text=' }
88
+ case 'drop':
89
+ if (!hasTarget)
90
+ return { valid: false, error: 'drop needs sel= or text= (the drop target)' }
91
+ if (!args.files || !args.files.length)
92
+ return { valid: false, error: 'drop needs files= (array of file names)' }
93
+ return { valid: true }
94
+ case 'clipboard':
95
+ if (args.mode === 'write' && args.value === undefined)
96
+ return { valid: false, error: 'clipboard write needs value=' }
97
+ return { valid: true }
53
98
  default:
54
99
  return { valid: false, error: `unknown action: ${type}` }
55
100
  }
@@ -57,18 +102,35 @@ export function resolveAction(type: string, args: ActionArgs): { valid: boolean,
57
102
 
58
103
  // --- Browser-side execution (client.ts only) ---
59
104
 
60
- export const INTERACTIVE = 'a, button, input, textarea, select, [role], [onclick], [tabindex], [contenteditable], [aria-label]'
61
-
62
- function visibleText(el: Element): string {
63
- const aria = el.getAttribute('aria-label')
64
- if (aria)
65
- return aria
66
- return (el.textContent || '').trim()
105
+ // 命中判定原语(INTERACTIVE/getDirectText/elementLabel/isVisible/reachable)的真身已搬进
106
+ // teact 内置的「DOM 命中」领域(teact/dom/candidates)—— write 路(这里)与 teact 的命中
107
+ // 轨迹快照共用同一份真理。re-export client.ts 的 `from '../core/action'` 无需改路径。
108
+ export { elementLabel, getDirectText, INTERACTIVE, isVisible, reachable } from 'teact/dom'
109
+ import { elementLabel, INTERACTIVE, isVisible, reachable } from 'teact/dom'
110
+
111
+ // A field whose live value must never be echoed: API keys, tokens, passwords. `.value`
112
+ // returns the real string even for type=password (masking is visual only), so reading it
113
+ // out in /dom, /query or /screen would leak the secret in plaintext — the exact hole a
114
+ // DOM-dumping inspector falls into. We redact instead. Signals (substring, no regex):
115
+ // type=password, autocomplete=*-password / one-time-code, or a name/placeholder/aria-label
116
+ // hinting a secret. Shared by every value-echoing reader so the redaction can't be bypassed
117
+ // by hitting a different endpoint.
118
+ const SECRET_HINTS = ['password', 'api key', 'apikey', 'api-key', 'api_key', 'secret', 'token', 'private key']
119
+ export function isSensitive(el: Element): boolean {
120
+ if ((el as HTMLInputElement).type === 'password')
121
+ return true
122
+ const ac = (el.getAttribute('autocomplete') || '').toLowerCase()
123
+ if (ac.includes('password') || ac === 'one-time-code')
124
+ return true
125
+ const hint = `${el.getAttribute('name') || ''} ${el.getAttribute('placeholder') || ''} ${el.getAttribute('aria-label') || ''}`.toLowerCase()
126
+ return SECRET_HINTS.some(h => hint.includes(h))
67
127
  }
68
128
 
69
- function isVisible(el: Element): boolean {
70
- const r = el.getBoundingClientRect()
71
- return r.width > 0 && r.height > 0
129
+ // The value to show for a field: real value, or a length-preserving redaction for secrets
130
+ // (so "is it empty / roughly how long" stays observable without exposing the secret).
131
+ export function safeValue(el: Element): string {
132
+ const v = (el as HTMLInputElement).value ?? ''
133
+ return isSensitive(el) ? (v ? `‹redacted ${v.length} chars›` : '') : v
72
134
  }
73
135
 
74
136
  export function findElement(sel?: string, text?: string): Element | null {
@@ -83,7 +145,10 @@ export function findElement(sel?: string, text?: string): Element | null {
83
145
  if (text) {
84
146
  const lower = text.toLowerCase()
85
147
  const candidates = Array.from(document.querySelectorAll(INTERACTIVE))
86
- return candidates.find(el => isVisible(el) && visibleText(el).toLowerCase().includes(lower)) || null
148
+ // Prefer a reachable match (clickable now); fall back to a merely-visible one so a
149
+ // covered/off-viewport target still resolves (the caller can scrollIntoView/realclick).
150
+ const matches = candidates.filter(el => isVisible(el) && elementLabel(el).toLowerCase().includes(lower))
151
+ return matches.find(reachable) ?? matches[0] ?? null
87
152
  }
88
153
  return null
89
154
  }
@@ -93,8 +158,8 @@ function clickableList(): string {
93
158
  // unreachable, so listing it (e.g. 90 chat items) is pure noise.
94
159
  const root = document.querySelector('[role="dialog"][data-state="open"]') ?? document
95
160
  const els = Array.from(root.querySelectorAll(INTERACTIVE))
96
- .filter(isVisible)
97
- .map(el => visibleText(el).slice(0, 40))
161
+ .filter(reachable)
162
+ .map(el => elementLabel(el).slice(0, 40))
98
163
  .filter(Boolean)
99
164
  return [...new Set(els)].slice(0, 30).join(' | ')
100
165
  }
@@ -103,10 +168,17 @@ export async function performAction(type: ActionType, args: ActionArgs): Promise
103
168
  try {
104
169
  switch (type) {
105
170
  case 'click': return doClick(args)
171
+ case 'realclick': return doResolveRealClick(args)
106
172
  case 'fill': return doFill(args)
107
173
  case 'press': return doPress(args)
108
174
  case 'wait': return await doWait(args)
109
175
  case 'screenshot': return await doScreenshot(args)
176
+ case 'query': return doQuery(args)
177
+ case 'assert': return doAssert(args)
178
+ case 'drag': return doDrag(args)
179
+ case 'scrollIntoView': return doScrollIntoView(args)
180
+ case 'drop': return doDrop(args)
181
+ case 'clipboard': return await doClipboard(args)
110
182
  }
111
183
  }
112
184
  catch (e) {
@@ -223,7 +295,25 @@ function doClick(args: ActionArgs): ActionResult {
223
295
  // Report when the mouse actually landed on something else (overlay/portal) — a
224
296
  // common cause of "I clicked X but nothing happened".
225
297
  const note = hit && hit !== el && !el.contains(hit) && !hit.contains(el) ? ` (hit <${hit.tagName.toLowerCase()}> on top)` : ''
226
- return { ok: true, detail: `clicked ${visibleText(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
298
+ return { ok: true, detail: `clicked ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()}${note}` }
299
+ }
300
+
301
+ // realclick resolves an element to a viewport-center (x,y) but does NOT synthetic-click.
302
+ // The trusted click is fired by whichever channel can produce isTrusted=true input:
303
+ // Electron's webContents.sendInputEvent (via electronAPI.invoke, in client.ts) or a Chrome
304
+ // extension's chrome.debugger (via the server's CDP queue). Synthetic events can't open a
305
+ // Radix ContextMenu — that's the whole reason this path exists. When x= & y= are given
306
+ // directly we pass them through unchanged.
307
+ function doResolveRealClick(args: ActionArgs): ActionResult {
308
+ if (args.x !== undefined && args.y !== undefined)
309
+ return { ok: true, x: args.x, y: args.y, detail: `resolved (${args.x}, ${args.y})` }
310
+ const el = findElement(args.sel, args.text)
311
+ if (!el)
312
+ return { ok: false, error: `no element for ${args.sel || args.text}`, detail: clickableList() }
313
+ const r = el.getBoundingClientRect()
314
+ const x = Math.round(r.left + r.width / 2)
315
+ const y = Math.round(r.top + r.height / 2)
316
+ return { ok: true, x, y, detail: `resolved ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()} at (${x}, ${y})` }
227
317
  }
228
318
 
229
319
  function doFill(args: ActionArgs): ActionResult {
@@ -250,8 +340,16 @@ function doFill(args: ActionArgs): ActionResult {
250
340
  return { ok: true, detail: `filled contenteditable, ${value.length} chars` }
251
341
  }
252
342
 
343
+ // React overrides the value setter on the element *instance* to track changes; a plain
344
+ // `input.value = x` writes through it so React's tracker never sees a diff and onChange
345
+ // never fires (controlled inputs stay empty). Call the *prototype* setter instead — the
346
+ // tracker observes the change and the synthetic onChange fires.
253
347
  const input = el as HTMLInputElement
254
- input.value = value
348
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
349
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
350
+ if (setter)
351
+ setter.call(input, value)
352
+ else input.value = value
255
353
  input.dispatchEvent(new Event('input', { bubbles: true }))
256
354
  input.dispatchEvent(new Event('change', { bubbles: true }))
257
355
  return { ok: true, detail: `filled ${value.length} chars` }
@@ -323,3 +421,161 @@ async function doScreenshot(args: ActionArgs): Promise<ActionResult> {
323
421
  return { ok: false, error: `screenshot failed: ${msg}` }
324
422
  }
325
423
  }
424
+
425
+ // The read-side twin of click/fill's `sel=`: instead of acting on the matched
426
+ // element, report the facts you'd otherwise reach for via /eval — count of
427
+ // matches, and per-element text / visible / assertion-relevant attrs. /wait
428
+ // answers "appears over time"; query answers "what is it now". Attrs are a
429
+ // whitelist (role, data-state, data-*, aria-*, value, disabled, checked, href,
430
+ // title) — a node's full class/style set is noise. Capped at 20 matches.
431
+ const QUERY_ATTRS = ['role', 'data-state', 'value', 'href', 'title']
432
+ const QUERY_PREFIXES = ['data-', 'aria-']
433
+
434
+ function elAttrs(el: Element): Record<string, string> {
435
+ const sensitive = isSensitive(el)
436
+ const out: Record<string, string> = {}
437
+ for (const attr of Array.from(el.attributes)) {
438
+ if (QUERY_ATTRS.includes(attr.name) || QUERY_PREFIXES.some(p => attr.name.startsWith(p)))
439
+ out[attr.name] = attr.name === 'value' && sensitive ? `‹redacted ${attr.value.length} chars›` : attr.value
440
+ }
441
+ const input = el as HTMLInputElement
442
+ if (typeof input.value === 'string' && out.value === undefined && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'))
443
+ out.value = safeValue(el)
444
+ if (input.disabled)
445
+ out.disabled = 'true'
446
+ if (input.checked)
447
+ out.checked = 'true'
448
+ return out
449
+ }
450
+
451
+ function doQuery(args: ActionArgs): ActionResult {
452
+ let els: Element[]
453
+ try {
454
+ els = Array.from(document.querySelectorAll(args.sel!))
455
+ }
456
+ catch {
457
+ return { ok: false, error: `invalid selector: ${args.sel} — URL-encode it (curl -G --data-urlencode 'sel=...')` }
458
+ }
459
+ const count = els.length
460
+ const matches = els.slice(0, 20).map(el => ({
461
+ text: elementLabel(el).slice(0, 80),
462
+ visible: isVisible(el),
463
+ attrs: elAttrs(el),
464
+ }))
465
+ const head = count > 20 ? `(showing 20 of ${count})\n` : ''
466
+ return { ok: true, detail: head + JSON.stringify({ count, matches }, null, 2) }
467
+ }
468
+
469
+ // Assert a domain variable (from __AIPEEK_SCREEN__) or a DOM element's text equals the
470
+ // expected value. Pass/fail with the actual value on failure — the chain stops and says
471
+ // "asserted X==Y, actual Z", not "the next step happened to miss".
472
+ function doAssert(args: ActionArgs): ActionResult {
473
+ let actual: string
474
+ if (args.screen) {
475
+ let domain: Record<string, unknown> = {}
476
+ try {
477
+ domain = window.__AIPEEK_SCREEN__?.() ?? {}
478
+ }
479
+ catch (e) {
480
+ return { ok: false, error: `__AIPEEK_SCREEN__ threw: ${e instanceof Error ? e.message : String(e)}` }
481
+ }
482
+ if (!(args.screen in domain))
483
+ return { ok: false, error: `no domain key "${args.screen}" — available: ${Object.keys(domain).join(', ') || '(none — app injected no __AIPEEK_SCREEN__)'}` }
484
+ actual = String(domain[args.screen])
485
+ }
486
+ else {
487
+ const el = findElement(args.sel, args.text)
488
+ if (!el)
489
+ return { ok: false, error: `no element for ${args.sel || args.text}` }
490
+ actual = elementLabel(el)
491
+ }
492
+ const target = args.screen ? `screen.${args.screen}` : (args.sel || args.text)
493
+ return actual === args.equals
494
+ ? { ok: true, detail: `${target} == ${args.equals}` }
495
+ : { ok: false, error: `asserted ${target} == "${args.equals}", actual "${actual}"` }
496
+ }
497
+
498
+ // Scroll an element into the viewport so it can be clicked. In a virtual list (tanstack-
499
+ // virtual, dnd-kit) a row off-screen isn't in the DOM at all — but a row rendered yet
500
+ // scrolled out of view is the common case, and this brings it back. Honest about the
501
+ // virtual case: if the element isn't found, say so (the row may need the list scrolled
502
+ // first via a container scroll, which /eval can do).
503
+ function doScrollIntoView(args: ActionArgs): ActionResult {
504
+ const el = findElement(args.sel, args.text)
505
+ if (!el)
506
+ return { ok: false, error: `no element for ${args.sel || args.text} — if it's in a virtual list it may not be rendered yet`, detail: clickableList() }
507
+ el.scrollIntoView({ block: 'center', inline: 'center' })
508
+ const r = el.getBoundingClientRect()
509
+ const inView = r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth
510
+ return { ok: true, detail: `scrolled ${elementLabel(el).slice(0, 40) || el.tagName.toLowerCase()} into view${inView ? '' : ' (still partly off-screen)'}` }
511
+ }
512
+
513
+ // Drag via a synthetic pointer sequence: pointerdown on source → a few pointermove steps
514
+ // (dnd-kit's PointerSensor has an activation distance constraint, so we move in increments
515
+ // past it) → pointerup on destination. Also fires the HTML5 drag events for libraries that
516
+ // use the native DnD API. dnd-kit relies on pointer capture + isTrusted in some paths; when
517
+ // synthetic events don't trigger the reorder, fall back to realclick's trusted channel
518
+ // (caller sees needsTrusted in the detail). Best-effort, honestly reported.
519
+ function doDrag(args: ActionArgs): ActionResult {
520
+ const src = findElement(args.sel, args.text)
521
+ if (!src)
522
+ return { ok: false, error: `no source element for ${args.sel || args.text}`, detail: clickableList() }
523
+ const dst = args.to ? document.querySelector(args.to) : null
524
+ if (!dst)
525
+ return { ok: false, error: `no destination element for to=${args.to}` }
526
+ const sr = src.getBoundingClientRect()
527
+ const dr = dst.getBoundingClientRect()
528
+ const sx = sr.left + sr.width / 2
529
+ const sy = sr.top + sr.height / 2
530
+ const dx = dr.left + dr.width / 2
531
+ const dy = dr.top + dr.height / 2
532
+ const ptr = (x: number, y: number, extra: PointerEventInit = {}) =>
533
+ ({ bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, ...extra })
534
+
535
+ src.dispatchEvent(new PointerEvent('pointerdown', ptr(sx, sy, { buttons: 1 })))
536
+ // Step through the path so dnd-kit clears its activation-distance constraint.
537
+ const steps = 8
538
+ for (let i = 1; i <= steps; i++) {
539
+ const x = sx + (dx - sx) * (i / steps)
540
+ const y = sy + (dy - sy) * (i / steps)
541
+ const over = (document.elementFromPoint(x, y) as HTMLElement | null) ?? dst as HTMLElement
542
+ over.dispatchEvent(new PointerEvent('pointermove', ptr(x, y, { buttons: 1 })))
543
+ }
544
+ ;(dst as HTMLElement).dispatchEvent(new PointerEvent('pointerup', ptr(dx, dy, { buttons: 0 })))
545
+ return { ok: true, detail: `dragged ${elementLabel(src).slice(0, 30) || src.tagName.toLowerCase()} → ${elementLabel(dst).slice(0, 30) || dst.tagName.toLowerCase()} (synthetic; if no reorder, retry with realclick — dnd-kit may need trusted pointer events)` }
546
+ }
547
+
548
+ // Drop synthetic files onto a target — the file-upload path without a native file picker
549
+ // (which synthetic clicks can't drive). Builds a DataTransfer with empty File objects of
550
+ // the given names and fires the dragenter→dragover→drop sequence libraries listen for.
551
+ function doDrop(args: ActionArgs): ActionResult {
552
+ const target = findElement(args.sel, args.text)
553
+ if (!target)
554
+ return { ok: false, error: `no drop target for ${args.sel || args.text}`, detail: clickableList() }
555
+ const dt = new DataTransfer()
556
+ for (const name of args.files ?? [])
557
+ dt.items.add(new File([''], name, { type: 'application/octet-stream' }))
558
+ const r = target.getBoundingClientRect()
559
+ const init = { bubbles: true, cancelable: true, composed: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, dataTransfer: dt }
560
+ for (const type of ['dragenter', 'dragover', 'drop'] as const)
561
+ target.dispatchEvent(new DragEvent(type, init))
562
+ return { ok: true, detail: `dropped ${dt.items.length} file(s) [${(args.files ?? []).join(', ')}] on ${elementLabel(target).slice(0, 30) || target.tagName.toLowerCase()}` }
563
+ }
564
+
565
+ // Read or write the clipboard. write seeds it (so a subsequent paste can be tested);
566
+ // read reports what the page put there (so a "copy" button can be verified). navigator.
567
+ // clipboard needs a focused document and (for read) permission — degrade with a clear
568
+ // error rather than hang.
569
+ async function doClipboard(args: ActionArgs): Promise<ActionResult> {
570
+ try {
571
+ if (args.mode === 'write') {
572
+ await navigator.clipboard.writeText(args.value ?? '')
573
+ return { ok: true, detail: `clipboard ← "${(args.value ?? '').slice(0, 60)}"` }
574
+ }
575
+ const text = await navigator.clipboard.readText()
576
+ return { ok: true, detail: `clipboard: "${text.slice(0, 200)}"` }
577
+ }
578
+ catch (e) {
579
+ return { ok: false, error: `clipboard ${args.mode ?? 'read'} failed: ${e instanceof Error ? e.message : String(e)} (needs document focus / permission)` }
580
+ }
581
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CompactState, ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
2
+ import { detailPerformance } from './perf'
2
3
  import { appStackFrames, compactUrl, formatValue, truncate } from './util'
3
4
 
4
5
  const SLOW_THRESHOLD = 1000
@@ -301,6 +302,7 @@ export function compact(raw: RawState): CompactState {
301
302
  network: compactNetwork(raw.network),
302
303
  errors: compactErrors(raw.errors),
303
304
  state: compactState(raw.state),
305
+ performance: raw.performance ? detailPerformance(raw.performance) : undefined,
304
306
  timestamp: raw.timestamp,
305
307
  counts: {
306
308
  console: raw.console.length,
@@ -1,5 +1,6 @@
1
- import type { ErrorEntry, LogEntry, NetworkRequest, RawState } from './types'
1
+ import type { ErrorEntry, LogEntry, NetworkRequest, PerformanceData, RawState } from './types'
2
2
  import { compactUI } from './compact'
3
+ import { detailPerformance } from './perf'
3
4
  import { appStackFrames, formatValue, truncate } from './util'
4
5
 
5
6
  export function detail(raw: RawState, section: string, index: string | undefined, full: boolean): string | null {
@@ -9,6 +10,7 @@ export function detail(raw: RawState, section: string, index: string | undefined
9
10
  case 'network': return detailNetwork(raw.network, index, full)
10
11
  case 'errors': return detailError(raw.errors, index, full)
11
12
  case 'state': return detailState(raw.state, index, full)
13
+ case 'profile': return raw.performance ? detailPerformance(raw.performance) : '(no perf data — is the tab foreground?)'
12
14
  default: return null
13
15
  }
14
16
  }
package/src/core/diff.ts CHANGED
@@ -1,4 +1,58 @@
1
- import type { DiffResult, RawState } from './types'
1
+ import type { DiffResult, ErrorEntry, LogEntry, NetworkRequest, RawState, ScreenSnap } from './types'
2
+
3
+ // The change a single action caused: the state-machine transition (view/modal/focus)
4
+ // plus any errors/failed requests it triggered. AI reads "what moved", then drills into
5
+ // /ui or /dom for detail if it needs more — the response carries the delta, not a snapshot.
6
+ // Pure: caller passes before/after snaps and the already-diffed error/request deltas.
7
+ export function diffScreen(
8
+ before: ScreenSnap,
9
+ after: ScreenSnap,
10
+ newErrors: LogEntry[],
11
+ newExceptions: ErrorEntry[],
12
+ newFailedRequests: NetworkRequest[],
13
+ ): string[] {
14
+ const lines: string[] = []
15
+ if (after.view !== before.view)
16
+ lines.push(`view: ${before.view} → ${after.view}`)
17
+ if (after.modal !== before.modal) {
18
+ if (after.modal === 'none')
19
+ lines.push(`modal: closed (${before.modal})`)
20
+ else if (before.modal === 'none')
21
+ lines.push(`modal: opened ${after.modal}`)
22
+ else
23
+ lines.push(`modal: ${before.modal} → ${after.modal}`)
24
+ }
25
+ if (after.focus !== before.focus)
26
+ lines.push(`focus: ${after.focus}`)
27
+ // The app's domain state machine (from __AIPEEK_SCREEN__) — these transitions are
28
+ // the ones a DOM-only projector can't see (流式 false → true never touches the DOM).
29
+ const beforeDomain = before.domain ?? {}
30
+ const afterDomain = after.domain ?? {}
31
+ for (const key of new Set([...Object.keys(beforeDomain), ...Object.keys(afterDomain)])) {
32
+ const b = stringifyDomain(beforeDomain[key])
33
+ const a = stringifyDomain(afterDomain[key])
34
+ if (b !== a)
35
+ lines.push(`${key}: ${b} → ${a}`)
36
+ }
37
+ for (const e of newErrors)
38
+ lines.push(`+error: ${e.text}`)
39
+ for (const e of newExceptions)
40
+ lines.push(`+exception: ${e.message}`)
41
+ for (const r of newFailedRequests)
42
+ lines.push(`+failed: ${r.method} ${r.url} → ${r.status || 'failed'}`)
43
+ return lines
44
+ }
45
+
46
+ // One-line, bounded, comparison-stable stringification of a domain value. Used both to
47
+ // detect change (string equality) and to render the transition. Objects compare by their
48
+ // JSON — good enough for the shallow domain maps __AIPEEK_SCREEN__ returns.
49
+ export function stringifyDomain(v: unknown): string {
50
+ if (v === null || v === undefined)
51
+ return String(v)
52
+ if (typeof v === 'object')
53
+ return JSON.stringify(v).slice(0, 80)
54
+ return String(v).slice(0, 80)
55
+ }
2
56
 
3
57
  export function diffState(prev: RawState | null, curr: RawState): DiffResult {
4
58
  if (!prev) {
package/src/core/emit.ts CHANGED
@@ -1,11 +1,12 @@
1
- import type { CheckResult, CompactState, DiffResult, RawState } from './types'
1
+ import type { CheckResult, CompactState, DiffResult, PerformanceData, RawState } from './types'
2
2
  import pc from 'picocolors'
3
3
  import { nameOf } from './compact'
4
+ import { compactPerformance } from './perf'
4
5
  import { compactUrl, truncate } from './util'
5
6
 
6
7
  // --- Full emit (for ?full) ---
7
8
 
8
- const SECTIONS = ['ui', 'console', 'network', 'errors', 'state'] as const
9
+ const SECTIONS = ['ui', 'console', 'network', 'errors', 'state', 'performance'] as const
9
10
  const COUNTED_SECTIONS: Record<string, keyof CompactState['counts']> = {
10
11
  console: 'console',
11
12
  network: 'network',
@@ -87,6 +88,17 @@ export function emitSummary(raw: RawState): string {
87
88
  for (const e of raw.errors) lines.push(` ${truncate(e.message, 150)}`)
88
89
  }
89
90
 
91
+ // perf — only show when jank (dropped >10%)
92
+ if (raw.performance) {
93
+ const all = raw.performance.buckets.find(b => b.name === '__all__')
94
+ if (all) {
95
+ const droppedPct = all.frames.total > 0 ? (all.frames.long / all.frames.total) * 100 : 0
96
+ if (droppedPct > 10) {
97
+ lines.push(compactPerformance(raw.performance))
98
+ }
99
+ }
100
+ }
101
+
90
102
  // state — 1-line: store names + key counts
91
103
  const storeNames = Object.keys(raw.state)
92
104
  if (storeNames.length) {