dsh-recent-usage-metrics 0.2.0 → 0.2.2

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.
package/README.en.md CHANGED
@@ -1,412 +1,455 @@
1
- # dsh-recent-usage-metrics
2
-
3
- **English** · [简体中文](README.md)
4
-
5
- Geek-level request metrics for DSH. It adds a third level — **Geek / 极客** — to the
6
- native **Performance & usage** preference in Settings → General, and while that level
7
- is active it keeps a live reading of the newest request in the composer status row
8
- plus a glass drawer with the last ten requests.
9
-
10
- ![The drawer: recent request metrics (English)](assets/screenshot-2.png)
11
-
12
- The drawer open: the step still streaming sits on top (its first-token figure climbing,
13
- speed shown as `—`), the last ten requests follow newest first, and their average closes
14
- the list. The status pill sits below it, its columns aligned with the table's.
15
-
16
- The UI is bilingual and follows the DSH language setting. The same drawer in Chinese:
17
-
18
- ![The drawer: recent request metrics (Chinese)](assets/screenshot-1.png)
19
-
20
- ## What you get
21
-
22
- **Settings → General → Performance & usage** now offers three levels:
23
-
24
- | Level | Behaviour |
25
- | --- | --- |
26
- | Compact / 简洁 | Shipped behaviour, unchanged |
27
- | Detailed / 详细 | Shipped behaviour, unchanged |
28
- | **Geek / 极客** | Everything below |
29
-
30
- Compact and Detailed keep writing the shipped `ui-chat` → `performanceUsage` field, so
31
- they behave exactly as before and stay in sync with the shipped row's data. Geek is a
32
- separate durable flag, because the shipped Chat schema only accepts two values.
33
-
34
- **Composer status row** (left of the shipped `55 轮 2307 步` pill):
35
-
36
- ```
37
- ⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
38
- ```
39
-
40
- time to first token · live generation speed · output tokens · total time · amortised
41
- speed (output ÷ total), separated by whitespace only. The pill reproduces the shipped
42
- statistics pill: the same `--dsw-alias-label-tertiary` foreground for both the text and
43
- the icon (the native pills never tint their glyph), and the same type scale. Height comes
44
- from padding + line-height, so it matches the native row.
45
-
46
- Each figure's digits are reserved with a **fixed width on the number element only** —
47
- never on the wrapping cell, which also carries the unit and would otherwise clip it. The
48
- estimate marker is a permanently reserved 1ch span that only toggles its paint. A value
49
- growing from `3.1` to `57.8`, a speed dropping from `328` to `9`, a `--` placeholder, and
50
- a `~` appearing or disappearing all leave the pill's width byte-identical — the centred
51
- composer row never shifts.
52
-
53
- The budgets come from the real distribution of a 433-reading session log, and each
54
- formatter is written to stay inside its budget:
55
-
56
- | Slot | Digits | Why that is enough |
57
- | --- | --- | --- |
58
- | first token / total | `4.5ch` | `57.8` normally; from 100 s up it prints whole seconds (`126`) |
59
- | speed / amortised | `4.5ch` | p50 `85`, max `328` in the log |
60
- | output tokens | `5.5ch` | p50 `219`; `123456` collapses to `123k` |
61
-
62
- The pill and the drawer use **disjoint class names** (`dsh-rum-part` vs `dsh-rum-col`).
63
- Sharing one cell class let the drawer's ellipsis rule clip the pill's units — the regression
64
- that produced digits with a truncated ` s` / ` tok/s`.
65
-
66
- Drawer columns are **measured from the pill at runtime**, not derived from `ch`. `ch` is
67
- the advance of `0`, while `tok/s` and the thin `s` are narrower, so an arithmetic column
68
- can never sit exactly under the rendered cell. On open the plugin reads each
69
- `[data-metric]` cell's `getBoundingClientRect().width` and publishes one custom property
70
- per metric (`--dsh-rum-col-ttft`, `--dsh-rum-col-speed`, `--dsh-rum-col-tokens`,
71
- `--dsh-rum-col-total`, `--dsh-rum-col-amortized`), which the row grid consumes. A
72
- `ResizeObserver` on the pill re-measures after a font-scale change or a metric swap, and a
73
- `ch` expression stays as the fallback for the frame before measurement lands.
74
-
75
- | Drawer column | Consumes | Sits under |
76
- | --- | --- | --- |
77
- | first token | `--dsh-rum-col-ttft` | the pill's first-token cell |
78
- | speed | `--dsh-rum-col-speed` | the pill's speed cell |
79
- | output tokens | `--dsh-rum-col-tokens` | the pill's token cell |
80
- | total | `--dsh-rum-col-total` | the pill's total-time cell |
81
- | amortised | `--dsh-rum-col-amortized` | the pill's amortised cell |
82
-
83
- Two further details make the edges actually coincide:
84
-
85
- - **Content edges, not box edges.** The pill pads its content by 8px, while the drawer
86
- stacks 16px of its own padding with a row's 6px. Aligning the two *boxes* therefore
87
- leaves the numbers visibly inset. Both insets are measured
88
- (`drawerContentInset` / `pillContentInset`) and subtracted, so the last figure in the
89
- drawer lines up with the last figure in the pill.
90
- - **Spare width goes left.** The drawer keeps a `min-width` so its own headers are not
91
- truncated; each row uses `justify-content: end`, pushing any surplus to the left so the
92
- final column stays flush. The drawer is also re-placed on the next animation frame,
93
- because the first pass can run before the measured columns have been applied.
94
-
95
- Click the pill to open the drawer; click it again, or press Escape, or click outside, to
96
- close it. There is no hover-open and no tooltip.
97
-
98
- ## Three states
99
-
100
- This plugin has two kinds of busy and one kind of idle, and all three look different:
101
-
102
- | State | Decided by | Pill icon | Drawer header | Live row |
103
- | --- | --- | --- | --- | --- |
104
- | **Model writing** | `model` is `null` | ⚡ pulsing bolt | `Streaming` | yes (11 rows) |
105
- | **Tools running** | `model` set, `endTime` `null` | **pulsing T** | **`Calling tools`** | yes (11 rows) |
106
- | **Idle** | `endTime` set, or no running step | still | `last N` | no (10 rows) |
107
-
108
- ### Why the tool phase is its own state
109
-
110
- This plugin measures **model output**, and `step/end` only arrives after the step's tool calls
111
- have all returned. Keying off `running` (which `step/end` clears) therefore kept the plugin in
112
- "streaming" for the entire tool phase after the reply had already finished: the bolt kept
113
- pulsing and the live row kept occupying an eleventh row.
114
-
115
- The field that separates the first two is `model`, which is `messageTime − startTime`:
116
-
117
- - the model is still writing → `null`
118
- - `assistant/message` has landed → a fixed value, **even while tools run**
119
-
120
- `endTime` then separates the tool phase from idle. Both fields come from the Host's
121
- `sessionStats` fold — the same data the Statistics page renders.
122
-
123
- ### The live row stays through the tool phase
124
-
125
- The request is genuinely still in flight, so it keeps its row; only the marker changes from the
126
- bolt to a T, and the drawer header switches from "Streaming" to "Calling tools". That makes all
127
- three states distinguishable at a glance.
128
-
129
- Both glyphs share **the same 14×14 svg box** (`.dsh-rum-trigger svg`) and the same `pulse`
130
- animation. Switching changes the shape and colour inside the box, never the box, so the status
131
- row does not shift. The T is drawn narrower on purpose so it does not read heavier than the bolt.
132
-
133
- ### Fallback
134
-
135
- A composition without the statistics fold has no `model` to read. There, `partial` and a
136
- Trajectory request can only mean the model is writing, so the state is always "model writing" —
137
- it never degrades to idle just because the fold is missing.
138
-
139
- **Drawer** — slides up from the status row, right-aligned to that pill, `position: fixed`
140
- on `document.body` so no scroll container can clip it. Its material is the shipped
141
- statistics dialog's own, copied token for token: the `--dsw-specific-menu` fill
142
- (`#303136f0` in dark mode, i.e. nearly opaque, the same surface behind the native
143
- numbers), the `--dsw-menu-backdrop-filter` 40px/150% blur, `--dsw-elevation-prominent`
144
- stroke and shadow, `--dsw-radius-lg`, and 16px padding. It lists the last ten requests,
145
- newest first, with no explanatory footer:
146
-
147
- | # | At | TTFT | Speed | Tokens | Time | Amortised |
148
- | --- | --- | --- | --- | --- | --- | --- |
149
- | 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
150
-
151
- The `At` column is when the request was **issued** (`step/start`), matching the Statistics
152
- view. It is not the end time: a row stamped 18:42:07 with a 4.6 s total actually finished
153
- at 18:42:12, so an end stamp would read as "it finished then" and would also order the list
154
- by completion rather than by issue.
155
-
156
- A new request slides in at the top; the row that falls off the ten-row cap is removed in
157
- the **same frame** rather than fading out on its own. An exit phase would keep an
158
- eleventh row laid out for the animation's duration, growing the drawer by a row and
159
- shrinking it back — a visible height jump on every insertion. Opening the drawer also
160
- establishes a baseline instead of animating, so the whole list never slides in at once.
161
-
162
- While a step is streaming, the drawer shows it as a live row above the history, and the
163
- status reading switches to a live style. Under a rule below the history sits a summary
164
- row — the average of the visible requests, in the same grid so each mean lands under its
165
- column. A column with no samples (say no TTFT on an interrupted request) prints a dash
166
- rather than a misleading zero.
167
-
168
- ## How the numbers are measured
169
-
170
- Everything is derived in the browser from the Chat snapshot the Conversation layer
171
- already maintains — the plugin adds no Host service, session event, or projection.
172
-
173
- - **Time to first token** — `timing.firstTokenTime − timing.stepStartTime`, the recorded
174
- moment the request was issued and the moment the first visible delta arrived.
175
- - **Total time** — `timing.completedTime − timing.stepStartTime`.
176
- - **Live speed** — output tokens ÷ decode time (`completedTime − firstTokenTime`).
177
- - **Output tokens** — the provider's own `usage.outputTokens`, which is the authority.
178
- It is read from `assistant/message.data.usage` (verified against a real session log:
179
- 262 of 262 settled steps carried it). `reasoningTokens` is already included in that
180
- count and is never added on top. OpenAI-shaped `completionTokens` spellings are also
181
- accepted for adapters that forward their usage verbatim.
182
- - **Amortised speed** — output tokens ÷ total time.
183
-
184
- The character estimate is a genuine fallback, not the normal path: it is used only for a
185
- step whose provider returned no usage at all (an interrupted or failed request), and such
186
- a row is marked `~`.
187
-
188
- Its coefficients are calibrated, not guessed. The content is not homogeneous: prose and
189
- reasoning tokenize near 4 ASCII characters per token, while tool-call arguments are JSON —
190
- short keys, punctuation, quoted values — and tokenize markedly worse, near 2.5. Measured
191
- against the provider's own `outputTokens` over 729 settled steps of a real session log,
192
- using the median absolute log error:
193
-
194
- | Rule | Median error | Estimate ÷ actual (p50) |
195
- | --- | --- | --- |
196
- | one shared `/4` for everything | 0.361 | 0.70 |
197
- | prose `/4`, tool args `/2.5` (shipped) | **0.128** | **1.00** |
198
-
199
- Splitting the two is what removed the bias. The old shared divisor systematically
200
- under-counted by about 30%, which is why the live amortised speed read low during
201
- generation; a fine grid search put the optimum at 0.125, so the shipped rule is within
202
- three thousandths of it. CJK stays at the conventional one token per character: the search
203
- suggested a lower cost, but only 45% of those steps contain any CJK, so that term is
204
- poorly identified in this sample and a fitted value would be noise.
205
-
206
- ### Tool arguments while they stream
207
-
208
- `legacy.partial.blocks` does **not** carry argument text during a call. The store's
209
- `tool-call-delta` branch copies `argsRaw` unchanged, so it stays an empty string until
210
- `block-end` fills it in one go. Counting only `blocks` therefore froze the live figure for
211
- the whole write phase — and that phase dominates real output: across 874 settled steps of
212
- a session log, tool arguments were a median **93%** of the estimated tokens, and 98% of
213
- steps had them.
214
-
215
- The fragments do exist during the call. They accumulate on the call's own `args.chunks`
216
- view, which `legacy.runningCalls` exposes for a call still in its `preparing` phase, so the
217
- live count reads them there and weights them at the tool coefficient. This is what makes
218
- the number climb while a file is being written instead of jumping when it finishes.
219
-
220
- The streaming row is approximate by nature — the Chat snapshot exposes the running step
221
- only as `legacy.partial` (turn, step, blocks), with no per-step start time. Two things keep
222
- that approximation honest:
223
-
224
- - **A step-level origin.** The settled figure is `firstToken − stepStart`, so the live one
225
- must use the same origin. Subtracting the *turn* start instead overstates TTFT by
226
- everything earlier in the turn: in a real session log the gap from turn start to a later
227
- step's start has a **174 s median** and reaches **2720 s**. That is why a live reading
228
- could show tens of seconds while reasoning yet a few seconds once the step settled.
229
- - **A tool result, not `completedTime`.** `timing.completedTime` is the `assistant/message`
230
- time, which lands *before* the step's tools run. Using it as the next step's origin counts
231
- the whole tool execution into that step's TTFT — measured, that gap has a 243 ms median but
232
- reaches **120 s**, which is exactly the "six seconds while streaming, two once it settles"
233
- symptom. A step's last `tool/result` sits within 1 ms of its own `step/end` and a median
234
- 9 ms before the next `step/start`, making it the tightest origin a browser-only plugin can
235
- reach. Attribution runs on the **seq window**, not timestamps: a resumed log replays steps
236
- and times repeat. Over 1113 steps the origin error fell from p50 100 ms / p99 20.8 s to
237
- **p50 9 ms / p99 486 ms**, and steps off by more than a second from 114 to **1**. A step
238
- that ran no tools falls back to its own `completedTime` (it was the turn's final answer, so
239
- nothing follows it), and a turn's first step to the turn's start (26 ms earlier).
240
- - **First delta, not first visible text.** The settled `firstTokenTime` counts reasoning and
241
- tool-call deltas too, and `legacy.partial` only appears once the step has visible content —
242
- so the moment a new partial key appears *is* the first-token moment.
243
- - **Counting from `step/start`, not from the first visible block.** Chat's `legacy.partial`
244
- waits for a visible block — a **1.9 s median** (p90 2.9 s) after the step begins, which is
245
- most of what a first-token latency measures. Reading only Chat therefore made the figure
246
- appear at its final value with no visible growth. The Trajectory domain instead opens its
247
- assistant record on `step/start` itself, and that record reaches the snapshot's `requests[]`
248
- with `status: "running"` and an exact `startedAt` — `step/start` is a location boundary, so
249
- the conversation assembler raises the publication priority from the definition's declared
250
- `none` to `immediate`. The plugin uses that to know the step is live and where it started,
251
- so the figure climbs as `now − startedAt` and then locks to `firstTokenTime − startedAt`,
252
- the same subtraction the settled row performs.
253
-
254
- ## First-token time for replayed history
255
-
256
- Seeding the drawer from an existing conversation used to leave the first-token column
257
- empty, even though the Trajectory panel showed a value for the same steps. The reason is
258
- a data-flow fact, not a missing calculation: `timing.firstTokenTime` is *streamed* state.
259
- The Chat store accumulates it from live token deltas, and the session log stores no live
260
- chunks — a real log held 787 settled messages and **zero** `assistant/live-chunk` events.
261
- A step replayed from that log therefore has no such field.
262
-
263
- The value is still recoverable. Every settled message carries `data.stream`, the whole
264
- chunk timeline with per-fragment timestamps, and the Trajectory domain rebuilds the mark
265
- from it (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`), summing
266
- `time0 + dt` across each packed run. That is why its panel can date history that the Chat
267
- snapshot cannot.
268
-
269
- The plugin reads those times from the Trajectory snapshot, which a slot entry reaches
270
- through the standard `useTrajectory` hook, and dates its rows with them. Two consequences
271
- worth knowing:
272
-
273
- - The Trajectory domain is another plugin's projection, so its times can arrive *after*
274
- the plugin seeded. Rows that were stored undated are repaired in place when that happens
275
- (`backfill`), instead of staying at `--` for the rest of the session.
276
- - A value that streamed live is never overwritten: the repair only touches rows whose
277
- first-token time is still absent.
278
-
279
- If a composition has no Trajectory domain, `useTrajectory` is simply absent, no index is
280
- built, and undated rows keep showing `--` — the previous behaviour, with no error.
281
-
282
- ## Install
283
-
284
- ```powershell
285
- # from the dsh GUI's Plugin Manager, or:
286
- # plugin_manager install_bundle with target = this directory
287
- ```
288
-
289
- The bundle links this directory into the profile, so editing `client/client.js` and
290
- reloading the page picks up the change. Host-side code never needs a restart because
291
- there is none.
292
-
293
- ## Layout
294
-
295
- | Path | Purpose |
296
- | --- | --- |
297
- | `client/client.js` | The whole feature: settings row, status reading, drawer |
298
- | `lib/index.js` | Empty Host half — makes the package a Loader entry so `./client` is published |
299
- | `cordis.patch.yml` | The one inserted Loader row |
300
- | `locale/en.json`, `locale/zh.json` | Plugin Manager title and description |
301
- | `icon.svg` | Plugin icon |
302
- | `test/lifecycle.cjs` | Runs the real client against a DOM/hook shim: metrics, ten-row cap, insertion order, click/open, disposal |
303
- | `test/repro-coldstart.cjs` | Regression: a cold page must paint Chinese and stay switchable both ways |
304
- | `test/repro-hmr.cjs` | Regression: a reload must not collide with the live dictionary claim |
305
- | `test/repro-log.cjs` | Verifies against a real session log (see below); skips itself when none exists |
306
-
307
- ```powershell
308
- node test/lifecycle.cjs
309
- node test/repro-coldstart.cjs
310
- node test/repro-hmr.cjs
311
- $env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
312
- ```
313
-
314
- ## Verifying against a real session log
315
-
316
- The Statistics page's figures come from the durable session log on disk:
317
-
318
- ```
319
- ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
320
- ```
321
-
322
- `test/repro-log.cjs` finds the newest one, folds it with the Statistics view's own
323
- `foldEvent` / `stepMetrics` algorithm, then checks the properties the drawer depends on:
324
- every step has a start time, first-token latency is non-negative and never exceeds the step's
325
- wall time, settled steps have a total, running steps do not, and the drawer selects the newest
326
- ten rows in descending order.
327
-
328
- That is far more reliable than comparing a few rows on screen: one run covers thousands of
329
- steps.
330
-
331
- Two traps the script handles:
332
-
333
- - **The log is many concatenated zstd frames.** A single `zstdDecompressSync` call returns
334
- only the first frame — a few hundred bytes. The frames must be split at the magic
335
- `28 B5 2F FD` and decompressed one by one, otherwise all you get is the `session` header.
336
- - **The first-token time lives in packed records.** The log contains no
337
- `assistant/live-chunk` events (client-only, never persisted); the moment is inside the
338
- `reasoning-chunks` / `tool-call-chunks` records of `assistant/message.data.stream`, as
339
- `time0` plus the cumulative `dt` offsets. Reading only loose `chunk` records leaves the
340
- first-token time at `--` for nearly every step (measured: 0% → 96%).
341
-
342
- ## Language after a restart
343
-
344
- `locale` is declared as a **hard dependency** (`inject: ['slots', 'locale']`) and `apply`
345
- reads `ctx.locale`. Probing with `ctx.get('locale')` was a real defect with a confusing
346
- symptom: the locale plugin itself waits on the connection, settings and remotes services,
347
- so on a cold page this plugin could apply first, read `undefined`, skip registering its
348
- dictionaries, and then render through a dictionary-less `t` — English, or raw keys, that
349
- stayed English even after switching the language back.
350
-
351
- `configForms` is optional (a profile may compose this bundle without the settings domain),
352
- so it is awaited with `ctx.inject(['configForms'], …)` rather than probed once. A single
353
- probe would latch the settings row onto the fallback preference for the whole session if
354
- the domain arrived late. Both choices follow the plugin guidance: required services go in
355
- `inject`, optional ones go in `ctx.inject`.
356
-
357
- The dictionary claim is parked on `globalThis` so a hot reload can release the previous
358
- generation's claim before registering; otherwise the second `register` for the namespace
359
- would throw and leave the UI dictionary-less — the same visible symptom by another route.
360
-
361
- ## Notes
362
-
363
- - The row is registered into the shipped `performance-usage` cell with `priority: -1`
364
- and the shipped `order: 30`, so it renders in the same place and the shipped cell is
365
- shadowed rather than duplicated.
366
- - Everything that outlives a render is created inside `ctx.effect`, so disabling the
367
- plugin restores the shipped row and removes every listener and style.
368
- - **Only the Geek flag is kept in `localStorage`** (`dsh.recentUsageMetrics.mode.v1`).
369
- The drawer's history is no longer persisted; it is derived from the session's own
370
- statistics, explained below.
371
-
372
- ## Where the drawer's rows come from
373
-
374
- Each row reads the **same data the shipped Statistics page renders** (the `stats` view
375
- target) instead of computing its own copy:
376
-
377
- ```js
378
- const target = uiConversation.binding(sessionId).target('stats')
379
- ```
380
-
381
- That data comes from the Host-side `sessionStats` fold over the session log, and each step
382
- carries `ttft`, `model`, `decode`, `tps`, `output` and `wall`. The Statistics page prints
383
- its first-token figure as `firstToken − step/start`, so the two agree row for row instead of
384
- being two parallel calculations kept in step by hand.
385
-
386
- **Why the history is no longer stored locally:**
387
-
388
- The drawer used to write its history to `localStorage` under one global key, with row keys
389
- of the form `${turn}:${step}`. **Every conversation numbers its turns and steps from 1**, so
390
- one conversation's `1:1` collided with another's — switching conversations showed the wrong
391
- figures and switching back showed them again. That was the reported symptom.
392
-
393
- Now:
394
-
395
- - Rows are rebuilt from that conversation's own statistics on every render, so there is no
396
- local copy that can go stale.
397
- - A reported session change clears them immediately, so the previous conversation's rows
398
- never appear under the new one while its data is still arriving.
399
- - A page reload rebuilds from the log; nothing extra needs persisting.
400
-
401
- **A view target only exists once something subscribes** (`getSnapshot` returns `undefined`
402
- before that). The plugin subscribes once per session at apply time. Activation is permanent
403
- and shared, so this does not start a second fold alongside the Statistics view.
404
-
405
- **Fallback**: in a composition without that data (`uiConversation` absent), the drawer
406
- returns to the Chat-snapshot plus Trajectory-domain calculation and marks estimates with `~`.
407
- That path only runs when the statistics data is missing.
408
-
409
- **The live reading does not touch the statistics during streaming**: its fold rebuilds the
410
- whole table on every chunk. The status pill uses a lighter path instead — the Trajectory
411
- domain's `step/start` time plus a 150 ms local tick — and reads the statistics once each
412
- step settles. That keeps the counting-up effect without adding fold work on every chunk.
1
+ # dsh-recent-usage-metrics
2
+
3
+ **English** · [简体中文](README.md)
4
+
5
+ Geek-level request metrics for DSH. It adds a third level — **Geek / 极客** — to the
6
+ native **Performance & usage** preference in Settings → General, and while that level
7
+ is active it keeps a live reading of the newest request in the composer status row
8
+ plus a glass drawer with the last ten requests.
9
+
10
+ ![The drawer: recent request metrics (English)](assets/screenshot-2.png)
11
+
12
+ The drawer open: the step still streaming sits on top (its first-token figure climbing,
13
+ speed shown as `—`), the last ten requests follow newest first, and their average closes
14
+ the list. The status pill sits below it, its columns aligned with the table's.
15
+
16
+ The UI is bilingual and follows the DSH language setting. The same drawer in Chinese:
17
+
18
+ ![The drawer: recent request metrics (Chinese)](assets/screenshot-1.png)
19
+
20
+ ## What you get
21
+
22
+ **Settings → General → Performance & usage** now offers three levels:
23
+
24
+ | Level | Behaviour |
25
+ | --- | --- |
26
+ | Compact / 简洁 | Shipped behaviour, unchanged |
27
+ | Detailed / 详细 | Shipped behaviour, unchanged |
28
+ | **Geek / 极客** | Everything below |
29
+
30
+ Compact and Detailed keep writing the shipped `ui-chat` → `performanceUsage` field, so
31
+ they behave exactly as before and stay in sync with the shipped row's data. Geek is a
32
+ separate durable flag, because the shipped Chat schema only accepts two values.
33
+
34
+ **Composer status row** (left of the shipped `55 轮 2307 步` pill):
35
+
36
+ ```
37
+ ⚡ 2.3 s 445 tok/s 1024 tok 4.6 s 223 tok/s
38
+ ```
39
+
40
+ time to first token · live generation speed · output tokens · total time · amortised
41
+ speed (output ÷ total), separated by whitespace only. The pill reproduces the shipped
42
+ statistics pill: the same `--dsw-alias-label-tertiary` foreground for both the text and
43
+ the icon (the native pills never tint their glyph), and the same type scale. Height comes
44
+ from padding + line-height, so it matches the native row.
45
+
46
+ Each figure's digits are reserved with a **fixed width on the number element only** —
47
+ never on the wrapping cell, which also carries the unit and would otherwise clip it. The
48
+ estimate marker is a permanently reserved 1ch span that only toggles its paint. A value
49
+ growing from `3.1` to `57.8`, a speed dropping from `328` to `9`, a `--` placeholder, and
50
+ a `~` appearing or disappearing all leave the pill's width byte-identical — the centred
51
+ composer row never shifts.
52
+
53
+ The budgets come from the real distribution of a 433-reading session log, and each
54
+ formatter is written to stay inside its budget:
55
+
56
+ | Slot | Digits | Why that is enough |
57
+ | --- | --- | --- |
58
+ | first token / total | `4.5ch` | `57.8` normally; from 100 s up it prints whole seconds (`126`) |
59
+ | speed / amortised | `4.5ch` | p50 `85`, max `328` in the log |
60
+ | output tokens | `5.5ch` | p50 `219`; `123456` collapses to `123k` |
61
+
62
+ The pill and the drawer use **disjoint class names** (`dsh-rum-part` vs `dsh-rum-col`).
63
+ Sharing one cell class let the drawer's ellipsis rule clip the pill's units — the regression
64
+ that produced digits with a truncated ` s` / ` tok/s`.
65
+
66
+ Drawer columns are **measured from the pill at runtime**, not derived from `ch`. `ch` is
67
+ the advance of `0`, while `tok/s` and the thin `s` are narrower, so an arithmetic column
68
+ can never sit exactly under the rendered cell. On open the plugin reads each
69
+ `[data-metric]` cell's `getBoundingClientRect().width` and publishes one custom property
70
+ per metric (`--dsh-rum-col-ttft`, `--dsh-rum-col-speed`, `--dsh-rum-col-tokens`,
71
+ `--dsh-rum-col-total`, `--dsh-rum-col-amortized`), which the row grid consumes. A
72
+ `ResizeObserver` on the pill re-measures after a font-scale change or a metric swap, and a
73
+ `ch` expression stays as the fallback for the frame before measurement lands.
74
+
75
+ | Drawer column | Consumes | Sits under |
76
+ | --- | --- | --- |
77
+ | first token | `--dsh-rum-col-ttft` | the pill's first-token cell |
78
+ | speed | `--dsh-rum-col-speed` | the pill's speed cell |
79
+ | output tokens | `--dsh-rum-col-tokens` | the pill's token cell |
80
+ | total | `--dsh-rum-col-total` | the pill's total-time cell |
81
+ | amortised | `--dsh-rum-col-amortized` | the pill's amortised cell |
82
+
83
+ Two further details make the edges actually coincide:
84
+
85
+ - **Content edges, not box edges.** The pill pads its content by 8px, while the drawer
86
+ stacks 16px of its own padding with a row's 6px. Aligning the two *boxes* therefore
87
+ leaves the numbers visibly inset. Both insets are measured
88
+ (`drawerContentInset` / `pillContentInset`) and subtracted, so the last figure in the
89
+ drawer lines up with the last figure in the pill.
90
+ - **Spare width goes left.** The drawer keeps a `min-width` so its own headers are not
91
+ truncated; each row uses `justify-content: end`, pushing any surplus to the left so the
92
+ final column stays flush. The drawer is also re-placed on the next animation frame,
93
+ because the first pass can run before the measured columns have been applied.
94
+
95
+ Click the pill to open the drawer; click it again, or press Escape, or click outside, to
96
+ close it. There is no hover-open and no tooltip.
97
+
98
+ ## Three states
99
+
100
+ This plugin has two kinds of busy and one kind of idle, and all three look different:
101
+
102
+ | State | Decided by | Pill icon | Drawer header | Live row |
103
+ | --- | --- | --- | --- | --- |
104
+ | **Model writing** | `model` is `null` | ⚡ pulsing bolt | `Streaming` | yes (11 rows) |
105
+ | **Tools running** | `model` set, `endTime` `null` | **pulsing T** | **`Calling tools`** | yes (11 rows) |
106
+ | **Idle** | `endTime` set, or no running step | still | `last N` | no (10 rows) |
107
+
108
+ ### Why the tool phase is its own state
109
+
110
+ This plugin measures **model output**, and `step/end` only arrives after the step's tool calls
111
+ have all returned. Keying off `running` (which `step/end` clears) therefore kept the plugin in
112
+ "streaming" for the entire tool phase after the reply had already finished: the bolt kept
113
+ pulsing and the live row kept occupying an eleventh row.
114
+
115
+ The field that separates the first two is `model`, which is `messageTime − startTime`:
116
+
117
+ - the model is still writing → `null`
118
+ - `assistant/message` has landed → a fixed value, **even while tools run**
119
+
120
+ `endTime` then separates the tool phase from idle. Both fields come from the Host's
121
+ `sessionStats` fold — the same data the Statistics page renders.
122
+
123
+ ### The live row stays through the tool phase
124
+
125
+ The request is genuinely still in flight, so it keeps its row; only the marker changes from the
126
+ bolt to a T, and the drawer header switches from "Streaming" to "Calling tools". That makes all
127
+ three states distinguishable at a glance.
128
+
129
+ Both glyphs share **the same 14×14 svg box** (`.dsh-rum-trigger svg`) and the same `pulse`
130
+ animation. Switching changes the shape and colour inside the box, never the box, so the status
131
+ row does not shift. The T is drawn narrower on purpose so it does not read heavier than the bolt.
132
+
133
+ ### Fallback
134
+
135
+ A composition without the statistics fold has no `model` to read. There, `partial` and a
136
+ Trajectory request can only mean the model is writing, so the state is always "model writing" —
137
+ it never degrades to idle just because the fold is missing.
138
+
139
+ **Drawer** — slides up from the status row, right-aligned to that pill, `position: fixed`
140
+ on `document.body` so no scroll container can clip it. Its material is the shipped
141
+ statistics dialog's own, copied token for token: the `--dsw-specific-menu` fill
142
+ (`#303136f0` in dark mode, i.e. nearly opaque, the same surface behind the native
143
+ numbers), the `--dsw-menu-backdrop-filter` 40px/150% blur, `--dsw-elevation-prominent`
144
+ stroke and shadow, `--dsw-radius-lg`, and 16px padding. It lists the last ten requests,
145
+ newest first, with no explanatory footer:
146
+
147
+ | # | At | TTFT | Speed | Tokens | Time | Amortised |
148
+ | --- | --- | --- | --- | --- | --- | --- |
149
+ | 1 | 18:42:07 | 2.3 s | 445 tok/s | 1024 tok | 4.6 s | 223 tok/s |
150
+
151
+ The `At` column is when the request was **issued** (`step/start`), matching the Statistics
152
+ view. It is not the end time: a row stamped 18:42:07 with a 4.6 s total actually finished
153
+ at 18:42:12, so an end stamp would read as "it finished then" and would also order the list
154
+ by completion rather than by issue.
155
+
156
+ A new request slides in at the top; the row that falls off the ten-row cap is removed in
157
+ the **same frame** rather than fading out on its own. An exit phase would keep an
158
+ eleventh row laid out for the animation's duration, growing the drawer by a row and
159
+ shrinking it back — a visible height jump on every insertion. Opening the drawer also
160
+ establishes a baseline instead of animating, so the whole list never slides in at once.
161
+
162
+ While a step is streaming, the drawer shows it as a live row above the history, and the
163
+ status reading switches to a live style. Under a rule below the history sits a summary
164
+ row — the average of the visible requests, in the same grid so each mean lands under its
165
+ column. A column with no samples (say no TTFT on an interrupted request) prints a dash
166
+ rather than a misleading zero.
167
+
168
+ ## How the numbers are measured
169
+
170
+ Everything is derived in the browser from the Chat snapshot the Conversation layer
171
+ already maintains — the plugin adds no Host service, session event, or projection.
172
+
173
+ - **Time to first token** — `timing.firstTokenTime − timing.stepStartTime`, the recorded
174
+ moment the request was issued and the moment the first visible delta arrived.
175
+ - **Total time** — `timing.completedTime − timing.stepStartTime`.
176
+ - **Live speed** — output tokens ÷ decode time (`completedTime − firstTokenTime`).
177
+ - **Output tokens** — the provider's own `usage.outputTokens`, which is the authority.
178
+ It is read from `assistant/message.data.usage` (verified against a real session log:
179
+ 262 of 262 settled steps carried it). `reasoningTokens` is already included in that
180
+ count and is never added on top. OpenAI-shaped `completionTokens` spellings are also
181
+ accepted for adapters that forward their usage verbatim.
182
+ - **Amortised speed** — output tokens ÷ total time.
183
+
184
+ The character estimate is a genuine fallback, not the normal path: it is used only for a
185
+ step whose provider returned no usage at all (an interrupted or failed request), and such
186
+ a row is marked `~`.
187
+
188
+ Its coefficients are calibrated, not guessed. The content is not homogeneous: prose and
189
+ reasoning tokenize near 4 ASCII characters per token, while tool-call arguments are JSON —
190
+ short keys, punctuation, quoted values — and tokenize markedly worse, near 2.5. Measured
191
+ against the provider's own `outputTokens` over 729 settled steps of a real session log,
192
+ using the median absolute log error:
193
+
194
+ | Rule | Median error | Estimate ÷ actual (p50) |
195
+ | --- | --- | --- |
196
+ | one shared `/4` for everything | 0.361 | 0.70 |
197
+ | prose `/4`, tool args `/2.5` (shipped) | **0.128** | **1.00** |
198
+
199
+ Splitting the two is what removed the bias. The old shared divisor systematically
200
+ under-counted by about 30%, which is why the live amortised speed read low during
201
+ generation; a fine grid search put the optimum at 0.125, so the shipped rule is within
202
+ three thousandths of it. CJK stays at the conventional one token per character: the search
203
+ suggested a lower cost, but only 45% of those steps contain any CJK, so that term is
204
+ poorly identified in this sample and a fitted value would be noise.
205
+
206
+ ### Tool arguments while they stream
207
+
208
+ `legacy.partial.blocks` does **not** carry argument text during a call. The store's
209
+ `tool-call-delta` branch copies `argsRaw` unchanged, so it stays an empty string until
210
+ `block-end` fills it in one go. Counting only `blocks` therefore froze the live figure for
211
+ the whole write phase — and that phase dominates real output: across 874 settled steps of
212
+ a session log, tool arguments were a median **93%** of the estimated tokens, and 98% of
213
+ steps had them.
214
+
215
+ The fragments do exist during the call. They accumulate on the call's own `args.chunks`
216
+ view, which `legacy.runningCalls` exposes for a call still in its `preparing` phase, so the
217
+ live count reads them there and weights them at the tool coefficient. This is what makes
218
+ the number climb while a file is being written instead of jumping when it finishes.
219
+
220
+ The streaming row is approximate by nature — the Chat snapshot exposes the running step
221
+ only as `legacy.partial` (turn, step, blocks), with no per-step start time. Two things keep
222
+ that approximation honest:
223
+
224
+ - **A step-level origin.** The settled figure is `firstToken − stepStart`, so the live one
225
+ must use the same origin. Subtracting the *turn* start instead overstates TTFT by
226
+ everything earlier in the turn: in a real session log the gap from turn start to a later
227
+ step's start has a **174 s median** and reaches **2720 s**. That is why a live reading
228
+ could show tens of seconds while reasoning yet a few seconds once the step settled.
229
+ - **A tool result, not `completedTime`.** `timing.completedTime` is the `assistant/message`
230
+ time, which lands *before* the step's tools run. Using it as the next step's origin counts
231
+ the whole tool execution into that step's TTFT — measured, that gap has a 243 ms median but
232
+ reaches **120 s**, which is exactly the "six seconds while streaming, two once it settles"
233
+ symptom. A step's last `tool/result` sits within 1 ms of its own `step/end` and a median
234
+ 9 ms before the next `step/start`, making it the tightest origin a browser-only plugin can
235
+ reach. Attribution runs on the **seq window**, not timestamps: a resumed log replays steps
236
+ and times repeat. Over 1113 steps the origin error fell from p50 100 ms / p99 20.8 s to
237
+ **p50 9 ms / p99 486 ms**, and steps off by more than a second from 114 to **1**. A step
238
+ that ran no tools falls back to its own `completedTime` (it was the turn's final answer, so
239
+ nothing follows it), and a turn's first step to the turn's start (26 ms earlier).
240
+ - **First delta, not first visible text.** The settled `firstTokenTime` counts reasoning and
241
+ tool-call deltas too, and `legacy.partial` only appears once the step has visible content —
242
+ so the moment a new partial key appears *is* the first-token moment.
243
+ - **Counting from `step/start`, not from the first visible block.** Chat's `legacy.partial`
244
+ waits for a visible block — a **1.9 s median** (p90 2.9 s) after the step begins, which is
245
+ most of what a first-token latency measures. Reading only Chat therefore made the figure
246
+ appear at its final value with no visible growth. The Trajectory domain instead opens its
247
+ assistant record on `step/start` itself, and that record reaches the snapshot's `requests[]`
248
+ with `status: "running"` and an exact `startedAt` — `step/start` is a location boundary, so
249
+ the conversation assembler raises the publication priority from the definition's declared
250
+ `none` to `immediate`. The plugin uses that to know the step is live and where it started,
251
+ so the figure climbs as `now − startedAt` and then locks to `firstTokenTime − startedAt`,
252
+ the same subtraction the settled row performs.
253
+
254
+ ## First-token time for replayed history
255
+
256
+ Seeding the drawer from an existing conversation used to leave the first-token column
257
+ empty, even though the Trajectory panel showed a value for the same steps. The reason is
258
+ a data-flow fact, not a missing calculation: `timing.firstTokenTime` is *streamed* state.
259
+ The Chat store accumulates it from live token deltas, and the session log stores no live
260
+ chunks — a real log held 787 settled messages and **zero** `assistant/live-chunk` events.
261
+ A step replayed from that log therefore has no such field.
262
+
263
+ The value is still recoverable. Every settled message carries `data.stream`, the whole
264
+ chunk timeline with per-fragment timestamps, and the Trajectory domain rebuilds the mark
265
+ from it (`firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream)`), summing
266
+ `time0 + dt` across each packed run. That is why its panel can date history that the Chat
267
+ snapshot cannot.
268
+
269
+ The plugin reads those times from the Trajectory snapshot, which a slot entry reaches
270
+ through the standard `useTrajectory` hook, and dates its rows with them. Two consequences
271
+ worth knowing:
272
+
273
+ - The Trajectory domain is another plugin's projection, so its times can arrive *after*
274
+ the plugin seeded. Rows that were stored undated are repaired in place when that happens
275
+ (`backfill`), instead of staying at `--` for the rest of the session.
276
+ - A value that streamed live is never overwritten: the repair only touches rows whose
277
+ first-token time is still absent.
278
+
279
+ If a composition has no Trajectory domain, `useTrajectory` is simply absent, no index is
280
+ built, and undated rows keep showing `--` — the previous behaviour, with no error.
281
+
282
+ ## Install
283
+
284
+ Published to npm as `dsh-recent-usage-metrics`:
285
+
286
+ ```powershell
287
+ # from the dsh GUI's Plugin Manager, or directly:
288
+ npm install dsh-recent-usage-metrics
289
+ ```
290
+
291
+ It can also be added to the profile's `package.json` dependencies and installed from there.
292
+
293
+ **Development mode**: point the profile dependency at this directory with `link:`, so editing
294
+ `client/client.js` and reloading the page picks up the change. Host-side code never needs a
295
+ restart because there is none.
296
+
297
+ ## Release
298
+
299
+ Pushing a `v*` tag publishes automatically through GitHub Actions, authenticated with OIDC
300
+ trusted publishing — **no npm token is involved**.
301
+
302
+ ```powershell
303
+ npm version patch # bump the version and create the tag
304
+ git push --follow-tags # push, and the release publishes itself
305
+ ```
306
+
307
+ The workflow first checks that the tag matches the `package.json` version and fails on a
308
+ mismatch, so a wrong version cannot slip out. Published tarballs carry a provenance statement,
309
+ visible on the package page.
310
+
311
+ ## Layout
312
+
313
+ | Path | Purpose |
314
+ | --- | --- |
315
+ | `client/client.js` | The whole feature: settings row, status reading, drawer |
316
+ | `lib/index.js` | Empty Host half — makes the package a Loader entry so `./client` is published |
317
+ | `cordis.patch.yml` | The one inserted Loader row |
318
+ | `locale/en.json`, `locale/zh.json` | Plugin Manager title and description |
319
+ | `icon.svg` | Plugin icon |
320
+ | `CHANGELOG.md` | What changed in each version |
321
+ | `.github/workflows/publish.yml` | Publishes to npm when a tag is pushed |
322
+ | `test/lifecycle.cjs` | Runs the real client against a DOM/hook shim: metrics, ten-row cap, insertion order, click/open, disposal |
323
+ | `test/repro-coldstart.cjs` | Regression: a cold page must paint Chinese and stay switchable both ways |
324
+ | `test/repro-hmr.cjs` | Regression: a reload must not collide with the live dictionary claim |
325
+ | `test/repro-log.cjs` | Verifies against a real session log (see below); skips itself when none exists |
326
+
327
+ ```powershell
328
+ node test/lifecycle.cjs
329
+ node test/repro-coldstart.cjs
330
+ node test/repro-hmr.cjs
331
+ $env:ELECTRON_RUN_AS_NODE='1'; & "C:\Cinstall\ProgramTools\DSH\DeepSeek Harness.exe" test\repro-log.cjs
332
+ ```
333
+
334
+ ## Verifying against a real session log
335
+
336
+ The Statistics page's figures come from the durable session log on disk:
337
+
338
+ ```
339
+ ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd
340
+ ```
341
+
342
+ `test/repro-log.cjs` finds the newest one, folds it with the Statistics view's own
343
+ `foldEvent` / `stepMetrics` algorithm, then checks the properties the drawer depends on:
344
+ every step has a start time, first-token latency is non-negative and never exceeds the step's
345
+ wall time, settled steps have a total, running steps do not, and the drawer selects the newest
346
+ ten rows in descending order.
347
+
348
+ That is far more reliable than comparing a few rows on screen: one run covers thousands of
349
+ steps.
350
+
351
+ Two traps the script handles:
352
+
353
+ - **The log is many concatenated zstd frames.** A single `zstdDecompressSync` call returns
354
+ only the first frame — a few hundred bytes. The frames must be split at the magic
355
+ `28 B5 2F FD` and decompressed one by one, otherwise all you get is the `session` header.
356
+ - **The first-token time lives in packed records.** The log contains no
357
+ `assistant/live-chunk` events (client-only, never persisted); the moment is inside the
358
+ `reasoning-chunks` / `tool-call-chunks` records of `assistant/message.data.stream`, as
359
+ `time0` plus the cumulative `dt` offsets. Reading only loose `chunk` records leaves the
360
+ first-token time at `--` for nearly every step (measured: 0% → 96%).
361
+
362
+ ## Language after a restart
363
+
364
+ `locale` is declared as a **hard dependency** (`inject: ['slots', 'locale']`) and `apply`
365
+ reads `ctx.locale`. Probing with `ctx.get('locale')` was a real defect with a confusing
366
+ symptom: the locale plugin itself waits on the connection, settings and remotes services,
367
+ so on a cold page this plugin could apply first, read `undefined`, skip registering its
368
+ dictionaries, and then render through a dictionary-less `t` — English, or raw keys, that
369
+ stayed English even after switching the language back.
370
+
371
+ `configForms` is optional (a profile may compose this bundle without the settings domain),
372
+ so it is awaited with `ctx.inject(['configForms'], …)` rather than probed once. A single
373
+ probe would latch the settings row onto the fallback preference for the whole session if
374
+ the domain arrived late. Both choices follow the plugin guidance: required services go in
375
+ `inject`, optional ones go in `ctx.inject`.
376
+
377
+ The dictionary claim is parked on `globalThis` so a hot reload can release the previous
378
+ generation's claim before registering; otherwise the second `register` for the namespace
379
+ would throw and leave the UI dictionary-less — the same visible symptom by another route.
380
+
381
+ ## Notes
382
+
383
+ - The row is registered into the shipped `performance-usage` cell with `priority: -1`
384
+ and the shipped `order: 30`, so it renders in the same place and the shipped cell is
385
+ shadowed rather than duplicated.
386
+ - Everything that outlives a render is created inside `ctx.effect`, so disabling the
387
+ plugin restores the shipped row and removes every listener and style.
388
+ - **Only the Geek flag is kept in `localStorage`** (`dsh.recentUsageMetrics.mode.v1`).
389
+ The drawer's history is no longer persisted; it is derived from the session's own
390
+ statistics, explained below.
391
+
392
+ ## Where the drawer's rows come from
393
+
394
+ Each row reads the **same data the shipped Statistics page renders** (the `stats` view
395
+ target) instead of computing its own copy:
396
+
397
+ ```js
398
+ const target = uiConversation.binding(sessionId).target('stats')
399
+ ```
400
+
401
+ That data comes from the Host-side `sessionStats` fold over the session log, and each step
402
+ carries `ttft`, `model`, `decode`, `tps`, `output` and `wall`.
403
+
404
+ **How the columns map:**
405
+
406
+ | Drawer column | Field | Note |
407
+ | --- | --- | --- |
408
+ | At | `startTime` | the `step/start` moment, same as the Statistics view |
409
+ | TTFT | `ttft` | `firstToken − startTime` |
410
+ | Speed | `tps` | `output ÷ decode` |
411
+ | Tokens | `output` | the provider's reported usage |
412
+ | **Time** | **`model`** | **model time, not the Statistics view's total** |
413
+ | Amortised | `output ÷ model` | this plugin's own figure |
414
+
415
+ **Why the total is `model` and not `wall`:**
416
+
417
+ The Statistics view's total column is `wall = endTime − startTime`, which **includes the step's
418
+ tool calls**. A step that thinks for 11.5 s and then runs a 31 s command shows `wall` 41.9 s but
419
+ `model` 11.5 s. This plugin measures model output only, so it reports `model` — a figure that
420
+ contains no tool time by construction, and is therefore final the moment the reply lands.
421
+
422
+ **Why the figures carry `~` while streaming:**
423
+
424
+ Until the model finishes, the Host has no `usage` from the provider, so `output` is `null`. The
425
+ plugin falls back to a character estimate (coefficients fitted per content type), which is why
426
+ those figures carry `~`. The moment `usage` arrives — which is essentially the same instant the
427
+ reply completes — the measured value takes over and the `~` disappears.
428
+
429
+ **Why the history is no longer stored locally:**
430
+
431
+ The drawer used to write its history to `localStorage` under one global key, with row keys
432
+ of the form `${turn}:${step}`. **Every conversation numbers its turns and steps from 1**, so
433
+ one conversation's `1:1` collided with another's — switching conversations showed the wrong
434
+ figures and switching back showed them again. That was the reported symptom.
435
+
436
+ Now:
437
+
438
+ - Rows are rebuilt from that conversation's own statistics on every render, so there is no
439
+ local copy that can go stale.
440
+ - A reported session change clears them immediately, so the previous conversation's rows
441
+ never appear under the new one while its data is still arriving.
442
+ - A page reload rebuilds from the log; nothing extra needs persisting.
443
+
444
+ **A view target only exists once something subscribes** (`getSnapshot` returns `undefined`
445
+ before that). The plugin subscribes once per session at apply time. Activation is permanent
446
+ and shared, so this does not start a second fold alongside the Statistics view.
447
+
448
+ **Fallback**: in a composition without that data (`uiConversation` absent), the drawer
449
+ returns to the Chat-snapshot plus Trajectory-domain calculation and marks estimates with `~`.
450
+ That path only runs when the statistics data is missing.
451
+
452
+ **The live reading does not touch the statistics during streaming**: its fold rebuilds the
453
+ whole table on every chunk. The status pill uses a lighter path instead — the Trajectory
454
+ domain's `step/start` time plus a 150 ms local tick — and reads the statistics once each
455
+ step settles. That keeps the counting-up effect without adding fold work on every chunk.