// rnx what-happened — show recent events from the semantic timeline // // the agent-facing read verb for the SootSim.bridges.timeline API. by default // shows everything since the previous CLI call from the same agent // identity (cursor model). after rendering, advances the cursor past // the events it displayed so the next call shows only new events. // // usage: // rnx what-happened # since last call // rnx what-happened --summary # one-line counts // rnx what-happened --all # full ring (no cursor) // rnx what-happened --since 5s # absolute window // rnx what-happened --kinds toast,fetch # filter // rnx what-happened --json # structured // rnx what-happened --limit 50 # cap (default 200) import { getCliIdentityKey } from '../current-sim' import { rnxExit } from '../run-rnx' import { createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge' import { formatTimelineEvent, formatTimelineSummary, inspectTimelineAdvanceCursor, inspectTimelineRecent, inspectTimelineSummary, NOISY_TIMELINE_KINDS, } from './inspect/core' import type { SootSimTimelineEvent, SootSimTimelineQuery } from '@rnx/globals' interface WhatHappenedOptions { port?: number verbose?: boolean } function parseSinceFlag(args: string[]): { since?: number; consumed: number[] } { const consumed: number[] = [] for (let i = 0; i < args.length; i++) { if (args[i] === '--since' && i + 1 < args.length) { consumed.push(i, i + 1) const v = args[i + 1].trim() // accept '5s', '500ms', or absolute ms epoch const m = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(v) if (m) { const n = Number(m[1]) const unit = m[2] ?? 'ms' const ms = unit === 's' ? n * 1000 : unit === 'm' ? n * 60_000 : n return { since: Date.now() - ms, consumed } } const asNum = Number(v) if (Number.isFinite(asNum) && asNum > 1_000_000_000_000) { return { since: asNum, consumed } } } } return { consumed } } function parseKindsFlag(args: string[]): { kinds?: string[]; consumed: number[] } { const consumed: number[] = [] for (let i = 0; i < args.length; i++) { if (args[i] === '--kinds' && i + 1 < args.length) { consumed.push(i, i + 1) return { kinds: args[i + 1] .split(',') .map((s) => s.trim()) .filter(Boolean), consumed, } } } return { consumed } } function parseLimitFlag(args: string[]): { limit?: number; consumed: number[] } { const consumed: number[] = [] for (let i = 0; i < args.length; i++) { if (args[i] === '--limit' && i + 1 < args.length) { consumed.push(i, i + 1) const n = Number(args[i + 1]) if (Number.isFinite(n) && n > 0) return { limit: n, consumed } } } return { consumed } } interface FlowSection { label: string events: SootSimTimelineEvent[] startedAt: number | null } // section the events by screen/route boundaries for `what-happened // --flow`. each screen-name (or route-path) becomes a section // header; every event that lands between two screen changes belongs // to the first one's section. events before the first screen change // land in an "initial state" section so nothing gets dropped. // // design note: this groups by *appearance* events only (phase === // 'enter' or no phase). disappearance events (`exit`) end the // current section but don't start a new one — they're listed inside // the section that contained them. function groupByScreen(events: SootSimTimelineEvent[]): FlowSection[] { const sections: FlowSection[] = [] let current: FlowSection = { label: 'initial state', events: [], startedAt: events[0]?.t ?? null, } sections.push(current) for (const ev of events) { current.events.push(ev) if (ev.kind === 'screen' || ev.kind === 'route') { const data = ev.data as Record | null const phase = data?.phase as string | undefined // only treat 'enter' (and the legacy no-phase form) as a // section boundary. an 'exit' event is the tail of a screen, // not the start of a new one. if (!phase || phase === 'enter' || phase === 'appear' || phase === 'active') { const name = (data?.name as string) || (data?.activeName as string) || (data?.path as string) || (data?.pathname as string) || ev.kind if (sections.length === 1 && current.events.length === 1) { // first screen event — rename "initial state" inline so it // doesn't show an empty initial-state section before it. current.label = `${ev.kind}: ${name}` } else { current = { label: `${ev.kind}: ${name}`, events: [], startedAt: ev.t, } sections.push(current) } } } } return sections } export async function runWhatHappened(args: string[], opts: WhatHappenedOptions) { const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: [ '--summary', '--all', '--json', '--no-advance', '--help', '-h', // `--flow` groups events under screen/route boundaries instead of // printing a strict chronological list. demo-friendly for the // "what just happened in this sim" recap. '--flow', // by default we hide high-frequency render/layout/scroll noise so // a single tap doesn't get buried under 1000+ rows. enable // `react-commit` with `rnx timeline start react-commit`, then pass // `--noisy` (or an explicit `--kinds react-commit,…` filter) when you // actually want to see them. '--noisy', ], stripValueFlags: ['--since', '--kinds', '--limit'], }) if (args.includes('--help') || args.includes('-h')) { console.log(` rnx what-happened — show recent events from the semantic timeline usage: rnx what-happened # since last CLI call rnx what-happened --summary # one-line counts rnx what-happened --all # full ring (ignore cursor) rnx what-happened --since 5s # absolute window rnx what-happened --kinds toast,fetch rnx what-happened --noisy # include react-commit/layout/scroll rnx what-happened --limit 50 rnx what-happened --json rnx what-happened --no-advance # don't advance cursor after read note: react-commit, layout, and scroll are opt-in/noisy events. enable them with "rnx timeline start ", then pass --noisy or include them in --kinds to see them. `) rnxExit(0) } const summaryMode = args.includes('--summary') const flowMode = args.includes('--flow') const allMode = args.includes('--all') const jsonMode = args.includes('--json') const noAdvance = args.includes('--no-advance') const noisyMode = args.includes('--noisy') const { since } = parseSinceFlag(args) const { kinds } = parseKindsFlag(args) const { limit } = parseLimitFlag(args) const cursorKey = getCliIdentityKey() const query: SootSimTimelineQuery = { limit: limit ?? 200, ...(kinds && kinds.length ? { kinds: kinds as SootSimTimelineQuery['kinds'] } : {}), ...(since !== undefined ? { since } : allMode ? {} : { sinceCursor: cursorKey }), } const bridge = createBridgeFromParsed(parsed) try { if (summaryMode) { const summary = await inspectTimelineSummary(bridge, query) if (jsonMode) { console.log(JSON.stringify(summary)) } else { const since_label = allMode ? 'all time' : since !== undefined ? `last ${((Date.now() - since) / 1000).toFixed(1)}s` : 'since last call' console.log(` ${since_label}: ${formatTimelineSummary(summary)}`) } // advance cursor past the summary window's end if (!noAdvance && !allMode && summary.lastAt) { await inspectTimelineAdvanceCursor(bridge, cursorKey, summary.lastAt) } return } const result = await inspectTimelineRecent(bridge, query) // hide high-frequency render/layout/scroll noise unless the caller // explicitly asked for it (--noisy or --kinds containing one of // these). this is the single most common reason `what-happened` // output drowns out the meaningful events for the user/agent. const userPickedNoisyKind = Array.isArray(kinds) && kinds.some((k) => NOISY_TIMELINE_KINDS.has(k)) const suppressNoisy = !noisyMode && !userPickedNoisyKind let suppressedNoisyCount = 0 if (suppressNoisy) { const kept = result.events.filter((ev) => { if (NOISY_TIMELINE_KINDS.has(ev.kind)) { suppressedNoisyCount += 1 return false } return true }) result.events = kept } if (jsonMode) { if (flowMode) { // grouped JSON shape — sections by screen/route. agents who want // a chronological stream can still get it from the default // shape (without --flow). console.log(JSON.stringify(groupByScreen(result.events), null, 2)) } else { console.log(JSON.stringify(result, null, 2)) } } else { if (result.events.length === 0) { // distinguish "truly no events" from "events present but all // filtered as noise". the second case is the much more common // misdiagnosis — agents think a tap did nothing when actually // it just produced react-commits and no shell-state change. if (suppressedNoisyCount > 0) { console.log( ` no non-noise events (${suppressedNoisyCount} react-commit/layout/scroll hidden)`, ) } else if (allMode) { console.log(' no events recorded') } else if (since !== undefined) { console.log(' no events in window') } else { console.log(' no new events since last call') } } else if (flowMode) { // section-per-screen render. each section heads with the // screen/route name and lists every event that landed in // that section in chronological order. unattributed events // (events that landed before the first screen change) go in // an "initial state" section. const anchor = result.events[0]?.t ?? null const sections = groupByScreen(result.events) const header = allMode ? `─── ${result.events.length} event(s) total — flow view ───` : since !== undefined ? `─── ${result.events.length} event(s) in last ${((Date.now() - since) / 1000).toFixed(1)}s — flow view ───` : `─── ${result.events.length} event(s) since last call — flow view ───` console.log(` ${header}`) for (const section of sections) { console.log( `\n ── ${section.label} (${section.events.length} event${section.events.length === 1 ? '' : 's'}) ──`, ) for (const ev of section.events) { console.log(formatTimelineEvent(ev, anchor)) } } } else { const anchor = result.events[0]?.t ?? null const header = allMode ? `─── ${result.events.length} event(s) total ───` : since !== undefined ? `─── ${result.events.length} event(s) in last ${((Date.now() - since) / 1000).toFixed(1)}s ───` : `─── ${result.events.length} event(s) since last call ───` console.log(` ${header}`) for (const ev of result.events) { console.log(formatTimelineEvent(ev, anchor)) } } // when at least one real event printed, append the suppression // note so the user knows the picture isn't complete. when the // window was entirely noise, the empty-state branch above already // mentioned it — don't double up. if (suppressedNoisyCount > 0 && result.events.length > 0) { process.stderr.write( `\n ${suppressedNoisyCount} high-frequency event(s) hidden (react-commit/layout/scroll)\n` + ` rerun with --noisy or --kinds react-commit,layout,scroll to include them\n`, ) } } if (!noAdvance && !allMode && result.watermark > 0) { await inspectTimelineAdvanceCursor(bridge, cursorKey, result.watermark) } } finally { bridge.close() } }