aipeek 0.2.7 → 0.2.9

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,250 @@
1
+ import type { PerformanceData, PerfStat, PerfBucketData } from './types.js'
2
+
3
+ // Verdict noise floors: a delta smaller than these is treated as measurement noise, not a real
4
+ // change. Frame %: ±3pp. Self-time fallback: 10% relative, but at least 5ms absolute.
5
+ const FRAME_NOISE_PCT = 3
6
+ const SELF_NOISE_REL = 0.1
7
+ const SELF_NOISE_MS = 5
8
+
9
+ const avgOf = (samples: number[]) =>
10
+ samples.length > 0 ? samples.reduce((s, v) => s + v, 0) / samples.length : 0
11
+
12
+ // Summarize accumulated stat into display shape. total/n/max are O(1) streaming all-time
13
+ // truths; avg derives from them. No p95 here: it would need a per-key sample buffer, which is
14
+ // near-window while total/n/max are all-time — putting two different sample sets in one row
15
+ // (a row implies one distribution). max already gives the worst case, avg the center; that's
16
+ // enough to decide "is this worth optimizing" without a same-row lie.
17
+ export function summarizeStat(stat: PerfStat) {
18
+ const avg = stat.n > 0 ? stat.total / stat.n : 0
19
+ return {
20
+ total: stat.total,
21
+ n: stat.n,
22
+ avg,
23
+ max: stat.max,
24
+ }
25
+ }
26
+
27
+ // Merge two stats (for __all__ aggregation or HMR accumulation)
28
+ export function mergeStat(a: PerfStat, b: PerfStat): PerfStat {
29
+ return {
30
+ total: a.total + b.total,
31
+ n: a.n + b.n,
32
+ max: Math.max(a.max, b.max),
33
+ }
34
+ }
35
+
36
+ // Which buckets a single hit (component/line/frame) writes to: the live view + the __all__
37
+ // aggregate. When the active view IS __all__, return it ONCE — else every hit double-counts
38
+ // into __all__ (n, total, frames all 2×). Mirrors client-patch.ts targetBuckets().
39
+ export function targetBuckets(active: string): string[] {
40
+ return active === '__all__' ? ['__all__'] : [active, '__all__']
41
+ }
42
+
43
+ // One-line summary: 23fps · 18% dropped · 4 longtasks(max 120ms) · hot: ChatList 340ms, Markdown 120ms
44
+ export function compactPerformance(p: PerformanceData): string {
45
+ const all = p.buckets.find(b => b.name === '__all__')
46
+ if (!all) return 'perf: (no data)'
47
+
48
+ const { total, long, samples } = all.frames
49
+ const fps = total > 0 ? Math.round((1000 / avgOf(samples)) * 10) / 10 : 0
50
+ const droppedPct = total > 0 ? Math.round((long / total) * 100) : 0
51
+
52
+ const parts: string[] = [`${fps}fps`, `${droppedPct}% dropped`]
53
+
54
+ if (p.longtasks.count > 0) {
55
+ parts.push(`${p.longtasks.count} longtasks(max ${Math.round(p.longtasks.max)}ms)`)
56
+ }
57
+
58
+ // Top 2 components by total
59
+ const topComps = Object.entries(all.components)
60
+ .map(([name, stat]) => ({ name, total: stat.total }))
61
+ .sort((a, b) => b.total - a.total)
62
+ .slice(0, 2)
63
+
64
+ if (topComps.length > 0 && p.mobxPatched) {
65
+ const hot = topComps.map(c => `${c.name} ${Math.round(c.total)}ms`).join(', ')
66
+ parts.push(`hot: ${hot}`)
67
+ }
68
+
69
+ return `perf: ${parts.join(' · ')}`
70
+ }
71
+
72
+ // Full report adapting litcode __recReport
73
+ export function detailPerformance(p: PerformanceData): string {
74
+ const lines: string[] = []
75
+
76
+ // Banner
77
+ const windowSec = (p.windowMs / 1000).toFixed(1)
78
+ lines.push(`=== Performance (${windowSec}s window) ===`)
79
+ if (p.hiddenFrames > 0) {
80
+ lines.push(`⚠ Tab was backgrounded — ${p.hiddenFrames} hidden frames skipped (rAF throttling lies about timing)`)
81
+ lines.push('For accurate profiling: keep tab FOREGROUND + run /profile/reset before reproducing')
82
+ }
83
+ lines.push(`MobX attribution: ${p.mobxPatched ? 'ON' : 'OFF'}`)
84
+ if (!p.mobxPatched) {
85
+ lines.push('(Component attribution disabled — set window.__AIPEEK_MOBX__={Reaction} in src/lib/mobx.ts to enable)')
86
+ }
87
+ lines.push('')
88
+
89
+ // longtasks
90
+ if (p.longtasks.count > 0) {
91
+ lines.push(`Longtasks (>50ms): ${p.longtasks.count}, max ${Math.round(p.longtasks.max)}ms`)
92
+ lines.push('')
93
+ }
94
+
95
+ // Buckets (always show __all__, then per-view only if >1 bucket)
96
+ const showPerView = p.buckets.length > 1
97
+ for (const bucket of p.buckets) {
98
+ if (bucket.name !== '__all__' && !showPerView) continue
99
+
100
+ lines.push(`--- ${bucket.name} ---`)
101
+
102
+ // Frame stats
103
+ const { total, long, max, samples } = bucket.frames
104
+ const droppedPct = total > 0 ? ((long / total) * 100).toFixed(1) : '0.0'
105
+ const avgFrame = avgOf(samples).toFixed(1)
106
+ lines.push(`Frames: ${total} total, ${long} dropped (${droppedPct}%), max ${Math.round(max)}ms, avg ${avgFrame}ms`)
107
+
108
+ // Components (top 20 by total, desc)
109
+ const comps = Object.entries(bucket.components)
110
+ .map(([name, stat]) => ({ name, ...summarizeStat(stat) }))
111
+ .sort((a, b) => b.total - a.total)
112
+ .slice(0, 20)
113
+
114
+ if (comps.length > 0 && p.mobxPatched) {
115
+ lines.push('Components (by total self-time):')
116
+ for (const c of comps) {
117
+ lines.push(` ${c.name.padEnd(30)} ${Math.round(c.total)}ms / ${c.n}× = ${c.avg.toFixed(1)}ms avg, max ${Math.round(c.max)}ms`)
118
+ }
119
+ } else if (!p.mobxPatched) {
120
+ lines.push('(no component data — attribution OFF)')
121
+ }
122
+
123
+ // Hot lines (top 15 by total time) — babel line-level instrumentation.
124
+ // Orthogonal to component attribution: points at the exact source line, not just the component.
125
+ // INCLUSIVE, not self-time: __line wraps each statement, so an outer statement's timing
126
+ // contains every nested __line below it (`const x = foo(bar())` counts foo+bar). Labeling
127
+ // this "self-time" would lie — you'd optimize a high-level line that just sums its callees.
128
+ const hotLines = Object.entries(bucket.lines ?? {})
129
+ .map(([label, stat]) => ({ label, ...summarizeStat(stat) }))
130
+ .sort((a, b) => b.total - a.total)
131
+ .slice(0, 15)
132
+
133
+ if (hotLines.length > 0) {
134
+ lines.push('Hot lines (by total inclusive time — contains nested calls):')
135
+ for (const l of hotLines) {
136
+ lines.push(` ${l.label.padEnd(36)} ${Math.round(l.total)}ms / ${l.n}× = ${l.avg.toFixed(1)}ms avg, max ${Math.round(l.max)}ms`)
137
+ }
138
+ }
139
+
140
+ lines.push('')
141
+ }
142
+
143
+ return lines.join('\n')
144
+ }
145
+
146
+ // Closed-loop verdict: did my fix work? Compares two snapshots (before/after) by component
147
+ // name + line label, lists per-key total-ms deltas, and a headline verdict driven by the
148
+ // __all__ frame dropped% change. This is what lets an automated fix→measure loop decide
149
+ // "keep it" vs "revert and try again" without eyeballing two reports.
150
+ export function diffPerformance(before: PerformanceData, after: PerformanceData): string {
151
+ const out: string[] = []
152
+
153
+ const beforeAll = before.buckets.find(b => b.name === '__all__')
154
+ const afterAll = after.buckets.find(b => b.name === '__all__')
155
+
156
+ const droppedPct = (b: PerfBucketData | undefined) =>
157
+ b && b.frames.total > 0 ? (b.frames.long / b.frames.total) * 100 : 0
158
+ const beforeDrop = droppedPct(beforeAll)
159
+ const afterDrop = droppedPct(afterAll)
160
+ const dropDelta = afterDrop - beforeDrop
161
+
162
+ // Total self-time across all components — the fallback ground truth when frames weren't
163
+ // exercised (e.g. a fix reproduced via direct calls, not real renders → 0%→0% frames).
164
+ const sumTotal = (b: PerfBucketData | undefined) =>
165
+ b ? Object.values(b.components).reduce((s, c) => s + c.total, 0) : 0
166
+ const beforeSelf = sumTotal(beforeAll)
167
+ const afterSelf = sumTotal(afterAll)
168
+ const selfDelta = afterSelf - beforeSelf
169
+
170
+ // Verdict: frame health is the user-visible truth. But frames are only comparable when BOTH
171
+ // snapshots actually sampled frames AND at least one saw drops. If the after-tab went hidden
172
+ // its frameLoop stops (frames.total→0), which would read as a false "0% dropped" win — so fall
173
+ // back to self-time (MobX self-time keeps recording in the background) whenever a side is blind.
174
+ const bothSampledFrames = (beforeAll?.frames.total ?? 0) > 0 && (afterAll?.frames.total ?? 0) > 0
175
+ const sawDrops = (beforeAll?.frames.long ?? 0) > 0 || (afterAll?.frames.long ?? 0) > 0
176
+ const framesExercised = bothSampledFrames && sawDrops
177
+
178
+ // Blind-window guard: a backgrounded after-tab records NEITHER frames (rAF frozen) NOR
179
+ // self-time (nothing re-renders, so Reaction.track never fires). Both signals are absent, so
180
+ // selfDelta = 0 - beforeSelf is a large negative → a FALSE "IMPROVED" off an empty window.
181
+ // (The fallback comment above assumed self-time survives backgrounding; live data disproved it
182
+ // — a hidden tab renders nothing.) No data is not a win: refuse to render any direction.
183
+ const afterBlind = (afterAll?.frames.total ?? 0) === 0 && afterSelf === 0
184
+ let verdict: string
185
+ if (afterBlind) {
186
+ verdict = 'NO DATA'
187
+ } else if (framesExercised) {
188
+ if (dropDelta < -FRAME_NOISE_PCT) verdict = 'IMPROVED'
189
+ else if (dropDelta > FRAME_NOISE_PCT) verdict = 'REGRESSED'
190
+ else verdict = 'UNCHANGED'
191
+ } else {
192
+ const floor = Math.max(SELF_NOISE_MS, beforeSelf * SELF_NOISE_REL)
193
+ if (selfDelta < -floor) verdict = 'IMPROVED'
194
+ else if (selfDelta > floor) verdict = 'REGRESSED'
195
+ else verdict = 'UNCHANGED'
196
+ }
197
+
198
+ out.push(`=== Perf diff: ${verdict} ===`)
199
+ if (afterBlind) {
200
+ out.push('after-window recorded no frames and no self-time — tab backgrounded or idle. Re-run with the tab focused and the interaction reproduced.')
201
+ out.push('')
202
+ return out.join('\n').trimEnd()
203
+ }
204
+ out.push(`Dropped frames: ${beforeDrop.toFixed(1)}% → ${afterDrop.toFixed(1)}% (${dropDelta >= 0 ? '+' : ''}${dropDelta.toFixed(1)}%)`)
205
+ if (!framesExercised && Math.round(selfDelta) !== 0) {
206
+ out.push(`Total self-time: ${Math.round(beforeSelf)}ms → ${Math.round(afterSelf)}ms (${selfDelta >= 0 ? '+' : ''}${Math.round(selfDelta)}ms) — frames flat, verdict by self-time`)
207
+ }
208
+ if (before.longtasks.count !== after.longtasks.count) {
209
+ out.push(`Longtasks: ${before.longtasks.count} → ${after.longtasks.count}`)
210
+ }
211
+ out.push('')
212
+
213
+ // Per-component total-ms deltas (union of names), biggest absolute change first.
214
+ const compDelta = keyedTotalDelta(beforeAll?.components ?? {}, afterAll?.components ?? {})
215
+ if (compDelta.length > 0) {
216
+ out.push('Components (Δ total self-time):')
217
+ for (const d of compDelta) out.push(' ' + formatDelta(d))
218
+ out.push('')
219
+ }
220
+
221
+ // Per-line total-ms deltas (only when line instrumentation was on).
222
+ const lineDelta = keyedTotalDelta(beforeAll?.lines ?? {}, afterAll?.lines ?? {})
223
+ if (lineDelta.length > 0) {
224
+ out.push('Hot lines (Δ total inclusive time):')
225
+ for (const d of lineDelta) out.push(' ' + formatDelta(d))
226
+ }
227
+
228
+ return out.join('\n').trimEnd()
229
+ }
230
+
231
+ interface KeyDelta { key: string, before: number, after: number, delta: number, isNew: boolean, gone: boolean }
232
+
233
+ function keyedTotalDelta(a: Record<string, PerfStat>, b: Record<string, PerfStat>): KeyDelta[] {
234
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)])
235
+ const rows: KeyDelta[] = []
236
+ for (const key of keys) {
237
+ const beforeT = a[key]?.total ?? 0
238
+ const afterT = b[key]?.total ?? 0
239
+ const delta = afterT - beforeT
240
+ if (Math.round(delta) === 0) continue // unchanged keys add noise
241
+ rows.push({ key, before: beforeT, after: afterT, delta, isNew: !a[key], gone: !b[key] })
242
+ }
243
+ return rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)).slice(0, 20)
244
+ }
245
+
246
+ function formatDelta(d: KeyDelta): string {
247
+ const sign = d.delta >= 0 ? '+' : ''
248
+ const tag = d.isNew ? ' (new)' : d.gone ? ' (gone)' : ''
249
+ return `${d.key.padEnd(36)} ${Math.round(d.before)}ms → ${Math.round(d.after)}ms (${sign}${Math.round(d.delta)}ms)${tag}`
250
+ }
package/src/core/types.ts CHANGED
@@ -29,16 +29,76 @@ export interface ErrorEntry {
29
29
  column?: number
30
30
  }
31
31
 
32
+ // One semantic page action — a state-machine transition. trusted=real human / real
33
+ // input; !trusted=aipeek synthetic. view/modal/focus are the post-settle projection
34
+ // snapshot; the UI change ("dialog opened") is the diff against the prior entry.
35
+ export interface ActionEntry {
36
+ type: string
37
+ target: string
38
+ value?: string
39
+ trusted: boolean
40
+ view?: string
41
+ modal?: string
42
+ focus?: string
43
+ ts: number
44
+ tab?: string // set only in the server's cross-tab /timeline merge; undefined per-tab
45
+ }
46
+
32
47
  export interface RawState {
48
+ tab?: string
33
49
  url: string
50
+ title?: string
51
+ visible?: boolean
34
52
  ui: string
35
53
  console: LogEntry[]
36
54
  network: NetworkRequest[]
37
55
  errors: ErrorEntry[]
56
+ actions?: ActionEntry[]
38
57
  state: Record<string, unknown>
58
+ performance?: PerformanceData
39
59
  timestamp: number
40
60
  }
41
61
 
62
+ // --- Performance profiler ---
63
+ //
64
+ // 语义分桶 profiler 的投影。三类正交信号(组件 self-time / 帧 dt / longtask)按 view 分桶,
65
+ // 答「哪个组件在烧帧」。client-patch 采集进 ring buffer,client 投影成此结构,core/perf.ts 纯渲染。
66
+
67
+ // 一段重复计时的累积统计。total/n/max 全是 O(1) 流式全程真值,avg=total/n 派生。
68
+ // 不存 samples:它过去只为 p95,而 p95 是近窗近似、和全程 total 不同源(放同一行即撒谎),已删。
69
+ export interface PerfStat {
70
+ total: number
71
+ n: number
72
+ max: number
73
+ }
74
+
75
+ // 一个语义桶(一个 view,或全局 __all__):组件 self-time map + 帧统计 + 行级 self-time map。
76
+ export interface PerfBucketData {
77
+ name: string
78
+ components: Record<string, PerfStat>
79
+ frames: { total: number, long: number, max: number, samples: number[] }
80
+ lines: Record<string, PerfStat> // "File.tsx:42:varName" → stat,babel 行级插桩
81
+ }
82
+
83
+ export interface PerformanceData {
84
+ windowMs: number // Date.now() - startedAt,采样窗口时长
85
+ hiddenFrames: number // 标签页隐藏期被跳过的帧(后台 rAF 节流会说谎,故不计入 frames)
86
+ mobxPatched: boolean // Reaction.track patch 是否生效(否则无组件归因)
87
+ buckets: PerfBucketData[] // __all__ 在前
88
+ longtasks: { count: number, max: number }
89
+ }
90
+
91
+ // One live client (browser tab / Electron window) seen by the server. lastSeen is the
92
+ // server-side arrival time of its most recent reply — used to address one tab (?tab=)
93
+ // and to age out stale entries from /tabs.
94
+ export interface TabInfo {
95
+ id: string
96
+ url: string
97
+ title: string
98
+ visible: boolean
99
+ lastSeen: number
100
+ }
101
+
42
102
  export interface CompactState {
43
103
  url: string
44
104
  ui: string
@@ -46,6 +106,7 @@ export interface CompactState {
46
106
  network: string
47
107
  errors: string
48
108
  state: string
109
+ performance?: string
49
110
  timestamp: number
50
111
  counts: {
51
112
  console: number
@@ -73,3 +134,15 @@ export interface DiffResult {
73
134
  uiGone: boolean
74
135
  clean: boolean
75
136
  }
137
+
138
+ // The state-machine projection at one instant — what a click/fill is diffed against
139
+ // before vs after it settles. view/modal/focus are aipeek's generic UI vars; `domain`
140
+ // is the app's own state machine, injected via window.__AIPEEK_SCREEN__() — the few
141
+ // variables that DON'T live in the DOM (是否流式 / 选了哪个模型 / 未读数), which a
142
+ // DOM-only projector can never synthesize. Empty when the app injects nothing.
143
+ export interface ScreenSnap {
144
+ view: string
145
+ modal: string
146
+ focus: string
147
+ domain: Record<string, unknown>
148
+ }
package/src/core/util.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ActionEntry, TabInfo } from './types'
2
+
1
3
  export function truncate(s: string, max: number): string {
2
4
  return s.length > max ? `${s.slice(0, max)}…` : s
3
5
  }
@@ -67,3 +69,116 @@ export function compactUrl(url: string, search?: number): string {
67
69
  return truncate(url, 80)
68
70
  }
69
71
  }
72
+
73
+ // A tab is "live" if it replied within STALE_MS (a closed tab stops replying, so its
74
+ // lastSeen freezes). One definition, shared by the server's roster/guard and formatTabs.
75
+ export const STALE_MS = 30_000
76
+ export const isLive = (t: TabInfo, now: number) => now - t.lastSeen < STALE_MS
77
+
78
+ // Render the live-tab roster — shared by GET /tabs and the 409 "which tab?" guard.
79
+ // `now` is server time. The visible one is marked first so the caller can pick.
80
+ export function formatTabs(tabs: TabInfo[], now: number): string {
81
+ const live = tabs.filter(t => isLive(t, now))
82
+ if (!live.length)
83
+ return '(no live tabs)'
84
+ return live
85
+ .sort((a, b) => Number(b.visible) - Number(a.visible) || a.id.localeCompare(b.id))
86
+ .map((t) => {
87
+ const age = Math.round((now - t.lastSeen) / 1000)
88
+ const mark = t.visible ? '● visible' : '○ background'
89
+ return `${t.id} ${mark} ${truncate(t.title || '(untitled)', 40)} ${compactUrl(t.url)} ${age}s ago`
90
+ })
91
+ .join('\n')}
92
+
93
+ // The single projection π for every "didn't get an answer" path: connection state → one
94
+ // actionable message. Defined once, pure (no closure, no clock), so no failure path can
95
+ // collapse two states that need different fixes (the law: ker π ⊆ ker f*). The five fibers
96
+ // each demand a different action, so each gets its own branch:
97
+ // addressed, live → handler threw / hung / read needs foreground (check console)
98
+ // addressed, not live → tab closed or mid self-heal (check /tabs, retry)
99
+ // broadcast, live tabs → handler threw or tab just closed (check /tabs + console)
100
+ // broadcast, sockets>0 → page open but client not injected/wrong server (check the port)
101
+ // broadcast, no socket → no browser pointed here at all (open the app)
102
+ // `socketCount` is the raw HMR-socket count (server.ws.clients.size) — the bit that separates
103
+ // "open but uninjected" from "no browser", which the aipeek roster alone can't see.
104
+ export function diagnose(tab: string | undefined, tabs: TabInfo[], now: number, socketCount: number, port: number): string {
105
+ if (tab) {
106
+ const t = tabs.find(x => x.id === tab)
107
+ return t && isLive(t, now)
108
+ ? `tab '${tab}' is connected but didn't answer in time — the handler likely threw or hung (bad selector, infinite loop, or a read that needs the tab foreground). Check /__aipeek/console.`
109
+ : `tab '${tab}' is not connected — it closed, or the server restarted and it hasn't re-handshaked yet. Check /__aipeek/tabs for live ids; if the page is mid self-heal, retry in ~2s.`
110
+ }
111
+ const live = tabs.filter(t => isLive(t, now))
112
+ if (live.length)
113
+ return `${live.length} tab(s) connected but none answered — a handler threw, or they just closed. Check /__aipeek/tabs (still live?) and /__aipeek/console (errors?).`
114
+ return socketCount > 0
115
+ ? `no aipeek tab registered, but ${socketCount} page(s) are connected to this dev server via HMR — the page is open yet the aipeek client didn't load. Is it served by THIS vite server on :${port}? A separate vite config / vite preview / production build won't inject it.`
116
+ : `no browser tab connected to this dev server on :${port} — open the app, or check you're hitting the right port.`
117
+ }
118
+
119
+ // Render the action ring as a cause-effect timeline. Each entry is a transition; the UI
120
+ // change (→ dialog opened / closed / view switched) is the diff of this entry's post-settle
121
+ // snapshot against the prior one *on the same tab*. `T`=trusted (human), `S`=synthetic
122
+ // (aipeek). With anchorTs set, the matching entry is bracketed by "你当前的行为" dividers so
123
+ // the model reads [earlier] → its own action → [what followed]. Pure: no DOM. Shared by the
124
+ // single-tab `recent actions` tail (client.ts) and the cross-tab GET /timeline (server).
125
+ export function formatActions(entries: ActionEntry[], anchorTs?: number): string {
126
+ if (!entries.length)
127
+ return '(no actions)'
128
+ const sorted = [...entries].sort((a, b) => a.ts - b.ts)
129
+ // Tag lines with their tab only when the stream spans >1 tab (the cross-tab /timeline).
130
+ // Single-tab `recent actions` stays byte-identical — no tab prefix. uiChange diffs per
131
+ // tab, so an interleaved A/B stream doesn't read B's modal as A's transition.
132
+ const multiTab = new Set(sorted.map(e => e.tab).filter(Boolean)).size > 1
133
+ const lines: string[] = []
134
+ const prevByTab = new Map<string | undefined, ActionEntry>()
135
+ let anchored = false
136
+ for (const e of sorted) {
137
+ const isAnchor = anchorTs !== undefined && !anchored && e.ts === anchorTs
138
+ if (isAnchor) {
139
+ lines.push('—————— 你当前的行为 ——————')
140
+ anchored = true
141
+ }
142
+ const who = e.trusted ? 'T' : 'S'
143
+ const tag = multiTab ? `${e.tab ?? '?'} ` : ''
144
+ const val = e.value ? ` value="${e.value}"` : ''
145
+ const change = uiChange(prevByTab.get(e.tab), e)
146
+ lines.push(`${tag}[${who}] ${e.type} ${e.target}${val}${change ? ` → ${change}` : ''}`)
147
+ if (isAnchor)
148
+ lines.push('——————————————————————————')
149
+ prevByTab.set(e.tab, e)
150
+ }
151
+ return lines.join('\n')
152
+ }
153
+
154
+ // Append an action to the server's cross-tab ring, mutating it in place. Mirrors
155
+ // client-patch's input debounce at the aggregation point: a tab fires its hook on every
156
+ // keystroke (rewriting its in-page entry in place), so consecutive same-tab input on the
157
+ // same target rewrites the logged entry instead of appending — else "hello" becomes 5 rows
158
+ // (h/he/hel/…). Any other action ends the run. Ring-bounded by max. Pure logic, fixture-tested.
159
+ export function appendAction(log: ActionEntry[], tab: string, entry: ActionEntry, max: number): void {
160
+ const last = log[log.length - 1]
161
+ if (entry.type === 'input' && last?.type === 'input' && last.tab === tab && last.target === entry.target) {
162
+ log[log.length - 1] = { ...entry, tab }
163
+ return
164
+ }
165
+ log.push({ ...entry, tab })
166
+ if (log.length > max)
167
+ log.shift()
168
+ }
169
+
170
+ function uiChange(prev: ActionEntry | undefined, cur: ActionEntry): string {
171
+ if (cur.modal === undefined)
172
+ return ''
173
+ const prevModal = prev?.modal ?? 'none'
174
+ if (cur.modal !== prevModal) {
175
+ if (cur.modal === 'none')
176
+ return '弹窗关闭'
177
+ if (prevModal === 'none')
178
+ return `弹窗打开「${cur.modal}」`
179
+ return `弹窗切换「${cur.modal}」`
180
+ }
181
+ if (prev && cur.view !== undefined && cur.view !== prev.view)
182
+ return `视图 ${prev.view}→${cur.view}`
183
+ return ''
184
+ }