dsh-recent-usage-metrics 0.3.3 → 0.3.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## 0.3.5
2
+
3
+ - 修复:模型切换或缓存偏低时,高亮只染了数字列,时间与速度列仍是灰色——那三列各自设置了
4
+ 颜色,把行级颜色挡掉了。现在高亮会覆盖所有单元格,整行才真的变色。
5
+ - 总用时列新增悬浮提示,说明工具调用的用时:`工具调用 3 次 · 串行 12.4 s · 并行合并 5.1 s`。
6
+ 两个口径都报,因为差值正是并行省下的时间。无工具时写「无工具调用」,
7
+ 压缩行写明「压缩上下文不计工具时间」。
8
+ - 总用时的表头提示补上一句「不含工具调用时间」,把口径歧义说清。
9
+
10
+ ## 0.3.4
11
+
12
+ - 实时行也能显示模型名了:形态与编号行相同——本步窗口内有 `request/header` 判为切换、
13
+ 直接用它的模型;否则沿用上一步的模型。回复落地后由 `assistant/message.source` 覆盖。
14
+ 实测 3202 步:22 次切换全部命中,沿用 99.84%,唯一缺口是回合第一步且无 header。
15
+ - 实时行补上速度列的悬浮提示。未知时显示破折号而不是「使用对话模型」——后者读起来像
16
+ 一个事实,其实只是插件当时的假设。
17
+ - 新增两种行高亮:模型切换用业务蓝,缓存命中低于 90% 用警告黄。
18
+ 阈值来自实测:2921 条回复的中位数是 99.9%,1% 分位是 92.4%,90% 只标记 0.9% 的行。
19
+ 两者同时成立时文字取警告色、左侧色条保留业务蓝,两个信号都不丢。
20
+ 压缩行不参与缓存高亮——它已在速度列自报家门,且它的缓存数字描述的是重放而非应答。
21
+ - 未上报缓存读取的 provider(如 trae 把缓存并入输入)不会被当作 0% 而误标。
22
+
1
23
  ## 0.3.3
2
24
 
3
25
  - 输出 Token 列的悬浮提示里,输入 token 改为带千分位的完整数字(`370,683 tok`),
package/README.en.md CHANGED
@@ -152,8 +152,24 @@ Every drawer column and every header carries a hint:
152
152
  | Clock column | the full timestamp `11-06 10:47:41.828` |
153
153
  | Speed column | the model that served that row |
154
154
  | Tokens column | input tokens (with thousands separators, e.g. `370,683 tok`) and cache hit rate |
155
+ | Time column | how much of the request was tool time, both summed and parallel-merged |
155
156
  | Headers | what the column measures (the `#` header draws the four state glyphs) |
156
157
 
158
+ **Row highlights:**
159
+
160
+ | Trigger | Colour | Frequency |
161
+ | --- | --- | --- |
162
+ | The request switched models | business blue (text + left bar) | 0.7% |
163
+ | Cache hit below 90% | warn yellow (text + left bar) | 0.9% |
164
+
165
+ When both hold, the text takes the warn colour and the bar keeps the business colour. A
166
+ compaction row never takes the cache highlight.
167
+
168
+ **Where the model name comes from:** a `request/header` inside the step's window means a switch
169
+ and supplies the model; otherwise the previous step's model carries over, and a landed reply
170
+ overrides both via `assistant/message.source`. The live row follows the same rule, so the model
171
+ is visible while the reply is still streaming.
172
+
157
173
  The hints are a real overlay rather than `title`: `title` can only show plain text, and the
158
174
  `#` header has to draw glyphs.
159
175
 
package/README.md CHANGED
@@ -136,8 +136,21 @@ DSH 的极客级请求指标。它在 **设置 → 通用 → 性能与用量**
136
136
  | 时间列 | 完整时间戳 `11-06 10:47:41.828` |
137
137
  | 速度列 | 该行使用的模型 |
138
138
  | 输出 Token 列 | 输入 token(带千分位,如 `370,683 tok`)与缓存命中率 |
139
+ | 总用时列 | 工具调用的用时(串行累加与并行合并两个口径);无工具时说明没有调用 |
139
140
  | 表头 | 该列的含义(`#` 一列用图标展示四种状态) |
140
141
 
142
+ **行高亮:**
143
+
144
+ | 触发 | 颜色 | 实测频率 |
145
+ | --- | --- | --- |
146
+ | 该请求切换了模型 | 业务蓝(文字 + 左侧色条) | 0.7% |
147
+ | 缓存命中低于 90% | 警告黄(文字 + 左侧色条) | 0.9% |
148
+
149
+ 两者同时成立时文字取警告色、色条保留业务蓝。压缩行不参与缓存高亮。
150
+
151
+ **模型名的来源:** 本步窗口内的 `request/header` 判为切换,否则沿用上一步;回复落地后
152
+ 由 `assistant/message.source` 覆盖。实时行同样适用,所以生成期间就能看到模型名。
153
+
141
154
  提示是自建的浮层而不是 `title`:`title` 只能显示纯文本,而 `#` 列的说明要画出图标。
142
155
 
143
156
  ## 数字是怎么算出来的
package/client/client.js CHANGED
@@ -119,9 +119,13 @@ window.__ModuleLoader__.load({
119
119
  hintTtft: '从发出到收到第一个 token 的耗时',
120
120
  hintSpeed: '模型生成速度 = 输出 token ÷ 解码时长',
121
121
  hintTokens: '模型本次生成的 token 数',
122
- hintTotal: '请求耗时:从发出请求一直到模型输出结束',
122
+ hintTotal: '请求耗时:从发出请求一直到模型输出结束,不含工具调用时间',
123
123
  hintAmortized: '输出 token ÷ 总用时,包含请求时间和响应时间,更加能表明本地到模型输出完整链路的速度',
124
124
  tokensHint: '输入 {input} · 缓存命中 {hit}',
125
+ toolHintOne: '工具调用 {count} 次 · {total}',
126
+ toolHintTwo: '工具调用 {count} 次 · 串行 {total} · 并行合并 {wall}',
127
+ toolHintNone: '无工具调用',
128
+ toolHintCompaction: '压缩上下文不计工具时间',
125
129
  }
126
130
 
127
131
  const en = {
@@ -177,9 +181,13 @@ window.__ModuleLoader__.load({
177
181
  hintTtft: 'Time from issuing the request to receiving its first token',
178
182
  hintSpeed: 'Generation speed = output tokens ÷ decode time',
179
183
  hintTokens: 'Tokens this reply generated',
180
- hintTotal: 'Request time: from issuing the request until the model finished replying',
184
+ hintTotal: 'Request time: from issuing the request until the model finished replying, not counting tool calls',
181
185
  hintAmortized: 'Output tokens ÷ total time, including both queueing and response — the throughput of the whole local-to-model path',
182
186
  tokensHint: 'Input {input} · cache hit {hit}',
187
+ toolHintOne: 'Tool calls: {count} · {total}',
188
+ toolHintTwo: 'Tool calls: {count} · serial {total} · parallel-merged {wall}',
189
+ toolHintNone: 'No tool calls',
190
+ toolHintCompaction: 'A compaction spends no time on tools',
183
191
  }
184
192
 
185
193
  const STYLE_TEXT = `
@@ -450,6 +458,26 @@ window.__ModuleLoader__.load({
450
458
  color: var(--dsw-alias-label-tertiary);
451
459
  text-align: left;
452
460
  }
461
+ /*
462
+ * A highlighted row colours EVERY cell, not just the ones that inherit.
463
+ *
464
+ * The index, clock and primary cells each set their own colour, so without this they stay
465
+ * grey inside a blue or yellow row and the highlight reads as a stray word rather than as a
466
+ * whole row. Their own rules stay in place for every unhighlighted row.
467
+ *
468
+ * The specificity is deliberately higher than each of those cell rules: one class plus two
469
+ * attribute selectors beats a plain class selector.
470
+ */
471
+ .dsh-rum-item[data-switched="true"] .dsh-rum-col,
472
+ .dsh-rum-item[data-switched="true"] .dsh-rum-index,
473
+ .dsh-rum-item[data-switched="true"] .dsh-rum-clock {
474
+ color: inherit;
475
+ }
476
+ .dsh-rum-item[data-lowcache="true"] .dsh-rum-col,
477
+ .dsh-rum-item[data-lowcache="true"] .dsh-rum-index,
478
+ .dsh-rum-item[data-lowcache="true"] .dsh-rum-clock {
479
+ color: inherit;
480
+ }
453
481
  /*
454
482
  * The index cells are flex boxes so a row number and the streaming dot share one alignment
455
483
  * rule. With inline content, a 6px dot aligned on the text baseline sits visibly lower than
@@ -481,6 +509,30 @@ window.__ModuleLoader__.load({
481
509
  .dsh-rum-live-row {
482
510
  background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent);
483
511
  }
512
+ /*
513
+ * Two reasons a finished row is worth a second look.
514
+ *
515
+ * Both use the theme's own state tokens, never a literal colour, so they follow dark mode and
516
+ * any future palette change. The text colour carries the meaning; the left bar makes the row
517
+ * findable when the drawer is scanned rather than read.
518
+ *
519
+ * Cache-outranks-switch: a cold prompt is a performance fact the user can act on, while a model
520
+ * change is context. When both are true the warn colour wins and the bar keeps the business
521
+ * colour, so neither signal is lost.
522
+ */
523
+ .dsh-rum-item[data-switched="true"] {
524
+ color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
525
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
526
+ }
527
+ .dsh-rum-item[data-lowcache="true"] {
528
+ color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
529
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
530
+ }
531
+ /* The bar keeps the switch signal when a cold prompt outranks it in the text. */
532
+ .dsh-rum-item[data-switched="true"][data-lowcache="true"] {
533
+ color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-state-warn-label));
534
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));
535
+ }
484
536
  /*
485
537
  * The streaming marker: the same bolt the status pill pulses, sized for the index column.
486
538
  *
@@ -1060,6 +1112,16 @@ window.__ModuleLoader__.load({
1060
1112
  provider: null,
1061
1113
  context: numberOrNull(stats.context),
1062
1114
  cacheRead: numberOrNull(stats.cacheRead),
1115
+ /*
1116
+ * Tool time, for the total column hint. `tool` sums each call from its dispatch to its
1117
+ * result, so three parallel 10 s tools report 30 s; `toolWall` unions the intervals, so
1118
+ * the same three report 10 s. Both are reported because the gap between them is exactly
1119
+ * how much the parallelism bought.
1120
+ */
1121
+ toolMs: numberOrNull(stats.tool),
1122
+ toolWallMs: numberOrNull(stats.toolWall),
1123
+ /** How many calls the step made, from the fold's own call list. */
1124
+ toolCalls: Array.isArray(stats.calls) ? stats.calls.length : null,
1063
1125
  cacheHit: numberOrNull(stats.cacheHit),
1064
1126
  }
1065
1127
  }
@@ -1196,11 +1258,98 @@ window.__ModuleLoader__.load({
1196
1258
  const provider = typeof meta.provider === 'string' && meta.provider !== '' ? meta.provider : null
1197
1259
  if (model === null && provider === null) continue
1198
1260
  if (typeof node.turn !== 'number' || typeof node.step !== 'number') continue
1199
- index.set(`${node.turn}:${node.step}`, { provider, model })
1261
+ index.set(`${node.turn}:${node.step}`, { provider, model, switched: false, source: 'trajectory' })
1200
1262
  }
1201
1263
  return index
1202
1264
  }
1203
1265
 
1266
+ /**
1267
+ * Which model a step used, and whether that request changed models.
1268
+ *
1269
+ * A `request/header` is written only when the routed model or tool set CHANGES, so its
1270
+ * placement is itself the signal:
1271
+ *
1272
+ * - inside a step's own seq window (from its `step/start` to the next one) → this request
1273
+ * changed the routed configuration, and the header names the model it switched to;
1274
+ * - absent → the step reused whatever was already routed, so it inherits the previous
1275
+ * step's model.
1276
+ *
1277
+ * Measured on a real 3202-step session: 22 steps carried a header and all 22 were genuine
1278
+ * switches; 3180 inherited, of which every one with a known predecessor matched the model
1279
+ * the reply later reported. The only gaps are a turn's first step with no header, where
1280
+ * nothing earlier is known and the model stays null.
1281
+ *
1282
+ * A finished reply overrides both, because `assistant/message.source` is the authority.
1283
+ */
1284
+ function modelPlanFromStats(statsSnapshot, known) {
1285
+ const plan = new Map()
1286
+ const turns = statsSnapshot?.turns
1287
+ if (!Array.isArray(turns)) return plan
1288
+ for (const turn of turns) {
1289
+ if (!Array.isArray(turn.steps)) continue
1290
+ const headers = []
1291
+ if (Array.isArray(turn.events)) {
1292
+ for (const event of turn.events) {
1293
+ if (event?.kind !== 'request-header') continue
1294
+ const at = numberOrNull(event.time)
1295
+ if (at === null) continue
1296
+ if (typeof event.model !== 'string' && typeof event.provider !== 'string') continue
1297
+ headers.push({ time: at, provider: event.provider ?? null, model: event.model ?? null })
1298
+ }
1299
+ headers.sort((left, right) => left.time - right.time)
1300
+ }
1301
+ const starts = []
1302
+ for (const step of turn.steps) {
1303
+ const at = numberOrNull(step.startTime)
1304
+ if (at === null || typeof step.step !== 'number') continue
1305
+ starts.push({ at, step: step.step })
1306
+ }
1307
+ if (starts.length === 0) continue
1308
+ starts.sort((left, right) => left.at - right.at)
1309
+
1310
+ /*
1311
+ * The model currently in force, carried forward while the plan is built.
1312
+ *
1313
+ * A step whose header names a switch becomes the new carrier, so the step AFTER it
1314
+ * inherits the switched-to model — not the one before the switch, which `known` alone
1315
+ * would give.
1316
+ */
1317
+ let inForce = null
1318
+
1319
+ for (let i = 0; i < starts.length; i += 1) {
1320
+ const key = `${turn.turn}:${starts[i].step}`
1321
+ const from = starts[i].at
1322
+ const until = i + 1 < starts.length ? starts[i + 1].at : Number.POSITIVE_INFINITY
1323
+ let inside = null
1324
+ for (const header of headers) {
1325
+ if (header.time < from) continue
1326
+ if (header.time >= until) break
1327
+ inside = header
1328
+ }
1329
+ if (inside !== null) {
1330
+ // The header was written for THIS request, so the model changed here.
1331
+ inForce = { provider: inside.provider, model: inside.model }
1332
+ plan.set(key, { provider: inside.provider, model: inside.model, switched: true, source: 'header' })
1333
+ continue
1334
+ }
1335
+ /*
1336
+ * No header: inherit. A finished reply outranks the carried model, because
1337
+ * `assistant/message.source` is the authority for a step that has settled.
1338
+ */
1339
+ const settled = known === undefined ? undefined : known.get(key)
1340
+ if (settled !== undefined && settled.model !== null) {
1341
+ inForce = { provider: settled.provider, model: settled.model }
1342
+ plan.set(key, { provider: settled.provider, model: settled.model, switched: false, source: 'reply' })
1343
+ continue
1344
+ }
1345
+ if (inForce !== null) {
1346
+ plan.set(key, { provider: inForce.provider, model: inForce.model, switched: false, source: 'inherit' })
1347
+ }
1348
+ }
1349
+ }
1350
+ return plan
1351
+ }
1352
+
1204
1353
  /**
1205
1354
  * Whether any step started after this compaction finished.
1206
1355
  *
@@ -1256,17 +1405,23 @@ window.__ModuleLoader__.load({
1256
1405
  */
1257
1406
  function historyFromStats(statsSnapshot, models) {
1258
1407
  const steps = statsSteps(statsSnapshot)
1408
+ /*
1409
+ * `models` carries the per-step attribution worked out from the trajectory nodes and the
1410
+ * turn's request headers. It answers two questions at once: which model, and whether this
1411
+ * request changed it — the drawer colours a row for the second.
1412
+ */
1413
+ const plan = modelPlanFromStats(statsSnapshot, models)
1259
1414
  const rows = []
1260
1415
  for (const entry of steps) {
1261
1416
  // A step only has a row once it has started; `step/start` is what supplies this.
1262
1417
  if (numberOrNull(entry.metrics.startTime) === null) continue
1263
1418
  if (!statsStepHasFigures(entry.metrics)) continue
1264
1419
  const row = readingOfStatsStep(entry)
1265
- // Attribute the request to the model that served it, for the speed column's tooltip.
1266
- const owner = models === undefined ? undefined : models.get(row.key)
1420
+ const owner = plan.get(row.key) ?? (models === undefined ? undefined : models.get(row.key))
1267
1421
  if (owner !== undefined) {
1268
1422
  row.model = owner.model
1269
1423
  row.provider = owner.provider
1424
+ row.switched = owner.switched === true
1270
1425
  }
1271
1426
  rows.push(row)
1272
1427
  }
@@ -1529,6 +1684,32 @@ window.__ModuleLoader__.load({
1529
1684
  return approximate ? t('approxTokensUnit', { value: rounded }) : t('tokensUnit', { value: rounded })
1530
1685
  }
1531
1686
 
1687
+ /**
1688
+ * Cache-hit rate below which a row is worth flagging.
1689
+ *
1690
+ * Measured over 2921 replies in a real session: the median is 99.9% and the 1st percentile
1691
+ * is 92.4%, because a warm conversation re-reads its prefix almost every time. 90% therefore
1692
+ * marks a genuinely cold request rather than ordinary variation, and flags only 0.9% of rows.
1693
+ * A threshold at 70% would flag 0.6% — nearly the same rows, with less headroom above the
1694
+ * noise.
1695
+ */
1696
+ const LOW_CACHE_HIT = 0.9
1697
+
1698
+ /**
1699
+ * Whether a row should be drawn as a low-cache-hit row.
1700
+ *
1701
+ * Only a REPORTED rate counts. A provider that folds cache reads into its input count (trae
1702
+ * does) reports none, and treating "not reported" as "0%" would flag every one of its rows.
1703
+ * A compaction is excluded: it already announces itself in the speed column, and its cache
1704
+ * figure describes replaying the conversation rather than serving a request.
1705
+ */
1706
+ function isLowCacheHit(row) {
1707
+ if (row.compaction === true) return false
1708
+ const rate = numberOrNull(row.cacheHit)
1709
+ if (rate === null) return false
1710
+ return rate < LOW_CACHE_HIT
1711
+ }
1712
+
1532
1713
  /**
1533
1714
  * A whole token count with thousands separators, for a hover hint.
1534
1715
  *
@@ -1661,15 +1842,62 @@ window.__ModuleLoader__.load({
1661
1842
  )
1662
1843
  }
1663
1844
 
1664
- /** The speed column's hint: which model served this row. */
1845
+ /**
1846
+ * The speed column's hint: which model served this row.
1847
+ *
1848
+ * A request with no attribution shows the dash rather than a guess. "Chat model" would read
1849
+ * as a fact about the request when it is only what the plugin would have assumed, and a
1850
+ * wrong model name is worse than none — that is the whole reason this column stopped
1851
+ * borrowing `request/header` rows.
1852
+ *
1853
+ * A compaction is different: it does not "have no model", it has one that is only reported
1854
+ * once the summary lands, so while it runs the generic wording is honest.
1855
+ */
1665
1856
  function modelHintText(row, t) {
1666
1857
  if (row.compaction === true) return compactionModelName(row, t)
1667
1858
  const model = typeof row.model === 'string' && row.model !== '' ? row.model : null
1668
1859
  const provider = typeof row.provider === 'string' && row.provider !== '' ? row.provider : null
1669
- if (model === null) return t('usingChatModel')
1860
+ if (model === null && provider === null) return t('none')
1861
+ if (model === null) return provider
1670
1862
  return provider === null ? model : `${provider} / ${model}`
1671
1863
  }
1672
1864
 
1865
+ /**
1866
+ * The total column's hint: how much of the request was tool time.
1867
+ *
1868
+ * Two figures, because they answer different questions. `tool` adds up each call, so it
1869
+ * says how much tool work there was; `toolWall` unions the intervals, so it says how much
1870
+ * wall time the tools occupied. Three parallel 10 s calls are 30 s of work in 10 s of
1871
+ * elapsed time, and the gap is what parallelism bought.
1872
+ *
1873
+ * Neither is part of the total above, which is model time only — the header says so.
1874
+ */
1875
+ function toolHintText(row, t) {
1876
+ if (row.compaction === true) return t('toolHintCompaction')
1877
+ const calls = numberOrNull(row.toolCalls)
1878
+ const serial = numberOrNull(row.toolMs)
1879
+ if (serial === null && calls === null) return t('toolHintNone')
1880
+ if (serial === null) return t('toolHintNone')
1881
+ const wall = numberOrNull(row.toolWallMs)
1882
+ const count = calls === null ? 0 : calls
1883
+ if (wall === null || wall === serial) {
1884
+ return t('toolHintOne', { count, total: secondsValue(serial, t) })
1885
+ }
1886
+ return t('toolHintTwo', {
1887
+ count,
1888
+ total: secondsValue(serial, t),
1889
+ wall: secondsValue(wall, t),
1890
+ })
1891
+ }
1892
+
1893
+ /** A duration as the hint spells it: seconds, one decimal, no unit label of its own. */
1894
+ function secondsValue(ms, t) {
1895
+ if (ms === null || ms === undefined || !Number.isFinite(ms)) return t('none')
1896
+ const seconds = ms / 1000
1897
+ const text = seconds < 100 ? seconds.toFixed(1) : String(Math.round(seconds))
1898
+ return `${text} s`
1899
+ }
1900
+
1673
1901
  /** The tokens column's hint: the input side, which the cell itself does not show. */
1674
1902
  function tokensHintText(row, t) {
1675
1903
  const none = t('none')
@@ -2158,6 +2386,7 @@ window.__ModuleLoader__.load({
2158
2386
  sessionTurnOpen,
2159
2387
  sessionRunning,
2160
2388
  sessionStats,
2389
+ models,
2161
2390
  ) {
2162
2391
  const partial = useChat((snapshot) => snapshot.legacy.partial)
2163
2392
  const nodes = useChat((snapshot) => snapshot.legacy.nodes)
@@ -2341,6 +2570,20 @@ window.__ModuleLoader__.load({
2341
2570
  * lag the fold.
2342
2571
  * 3. Reconstruction from the previous step's last tool result, then the turn's start.
2343
2572
  */
2573
+ /*
2574
+ * The model for the running step, worked out the same way the drawer does it: a header
2575
+ * written inside this step names a switch, otherwise the previous step's model carries
2576
+ * over. A finished reply overrides both, which is why the plan prefers it.
2577
+ *
2578
+ * This is what lets the live row show a model while it is still streaming instead of
2579
+ * waiting for `assistant/message` to land.
2580
+ */
2581
+ const livePlan =
2582
+ sessionStats === undefined ? undefined : modelPlanFromStats(sessionStats, models)
2583
+ const liveOwner =
2584
+ liveStep === null || livePlan === undefined
2585
+ ? undefined
2586
+ : livePlan.get(`${liveStep.turn}:${liveStep.step}`)
2344
2587
  /*
2345
2588
  * A compaction has no step to measure, so the reading is built from the compaction alone:
2346
2589
  * its own clock, its own span and its own model. Returning here keeps every step-shaped
@@ -2446,6 +2689,9 @@ window.__ModuleLoader__.load({
2446
2689
  : null,
2447
2690
  replyLanded: true,
2448
2691
  phase: livePhase,
2692
+ model: liveOwner?.model ?? null,
2693
+ provider: liveOwner?.provider ?? null,
2694
+ switched: liveOwner?.switched === true,
2449
2695
  compaction: activeCompaction,
2450
2696
  }
2451
2697
  }
@@ -2472,6 +2718,9 @@ window.__ModuleLoader__.load({
2472
2718
  amortized: tokens > 0 && elapsedMs !== null && elapsedMs > 0 ? tokens / (elapsedMs / 1000) : null,
2473
2719
  replyLanded: false,
2474
2720
  phase: livePhase,
2721
+ model: liveOwner?.model ?? null,
2722
+ provider: liveOwner?.provider ?? null,
2723
+ switched: liveOwner?.switched === true,
2475
2724
  compaction: activeCompaction,
2476
2725
  }
2477
2726
  }
@@ -2936,6 +3185,14 @@ window.__ModuleLoader__.load({
2936
3185
  return last.running !== false
2937
3186
  }, [sessionStats])
2938
3187
  const chatNodes = useChat((snapshot) => snapshot.legacy.nodes)
3188
+ /*
3189
+ * The per-step model attribution, computed once and shared by the live reading and the
3190
+ * history so the two can never disagree about which model a step used.
3191
+ */
3192
+ const models = useMemo(
3193
+ () => modelIndexFromTrajectory(trajectory),
3194
+ [trajectory],
3195
+ )
2939
3196
  const live = useLiveReading(
2940
3197
  useChat,
2941
3198
  geek,
@@ -2944,6 +3201,7 @@ window.__ModuleLoader__.load({
2944
3201
  sessionTurnOpen,
2945
3202
  sessionRunning,
2946
3203
  sessionStats,
3204
+ models,
2947
3205
  )
2948
3206
 
2949
3207
  const triggerRef = useRef(null)
@@ -2987,8 +3245,8 @@ window.__ModuleLoader__.load({
2987
3245
  () =>
2988
3246
  sessionStats === undefined
2989
3247
  ? undefined
2990
- : historyFromStats(sessionStats, modelIndexFromTrajectory(trajectory)),
2991
- [sessionStats, trajectory],
3248
+ : historyFromStats(sessionStats, models),
3249
+ [sessionStats, models],
2992
3250
  )
2993
3251
  /*
2994
3252
  * Fallback for a composition without the statistics target: derive rows from the Chat
@@ -3512,7 +3770,17 @@ window.__ModuleLoader__.load({
3512
3770
  live !== null
3513
3771
  ? h(
3514
3772
  'div',
3515
- { className: 'dsh-rum-row dsh-rum-live-row', role: 'row' },
3773
+ {
3774
+ className: 'dsh-rum-row dsh-rum-live-row',
3775
+ role: 'row',
3776
+ /*
3777
+ * The live row carries the same two highlights as a settled one, so a
3778
+ * model switch is visible the moment the request starts rather than
3779
+ * only once its row is numbered.
3780
+ */
3781
+ 'data-switched': live.switched === true ? 'true' : undefined,
3782
+ 'data-lowcache': isLowCacheHit(live) ? 'true' : undefined,
3783
+ },
3516
3784
  /*
3517
3785
  * A pulsing dot, no word.
3518
3786
  *
@@ -3574,7 +3842,18 @@ window.__ModuleLoader__.load({
3574
3842
  h('span', { className: 'dsh-rum-col', role: 'cell' }, secondsText(live.ttftMs, t)),
3575
3843
  h(
3576
3844
  'span',
3577
- { className: 'dsh-rum-col dsh-rum-primary', role: 'cell' },
3845
+ {
3846
+ className: 'dsh-rum-col dsh-rum-primary',
3847
+ role: 'cell',
3848
+ /*
3849
+ * The same hint the numbered rows carry: which model is answering.
3850
+ * A compaction names its own summarizer; a request names the model
3851
+ * the plan worked out, and falls back to the generic wording while
3852
+ * nothing is known yet — a turn's first step with no header.
3853
+ */
3854
+ onMouseEnter: (event) => hint.show(modelHintText(live, t), event),
3855
+ onMouseLeave: hint.hide,
3856
+ },
3578
3857
  live.compaction !== null && live.compaction !== undefined
3579
3858
  ? t('compactionSpeed')
3580
3859
  : speedText(live.speed, live.estimated === true, t),
@@ -3582,7 +3861,13 @@ window.__ModuleLoader__.load({
3582
3861
  h('span', { className: 'dsh-rum-col', role: 'cell' }, tokensText(live.tokens, live.estimated === true, t)),
3583
3862
  h(
3584
3863
  'span',
3585
- { className: 'dsh-rum-col', role: 'cell' },
3864
+ {
3865
+ className: 'dsh-rum-col',
3866
+ role: 'cell',
3867
+ /* Same hint as the numbered rows, so the meaning never changes. */
3868
+ onMouseEnter: (event) => hint.show(toolHintText(live, t), event),
3869
+ onMouseLeave: hint.hide,
3870
+ },
3586
3871
  secondsText(
3587
3872
  live.compaction !== null && live.compaction !== undefined
3588
3873
  ? compactionSpan(live.compaction, now)
@@ -3606,6 +3891,14 @@ window.__ModuleLoader__.load({
3606
3891
  'dsh-rum-row dsh-rum-item' +
3607
3892
  (entry.row.compaction === true ? ' dsh-rum-compaction-row' : ''),
3608
3893
  'data-phase': entry.phase,
3894
+ /*
3895
+ * Two independent highlights. A request that changed the routed model
3896
+ * gets the business colour; one whose cache hit fell below the threshold
3897
+ * gets the warn colour. Both are attributes rather than inline colours so
3898
+ * the stylesheet owns the tokens and the dark-mode behaviour.
3899
+ */
3900
+ 'data-switched': entry.row.switched === true ? 'true' : undefined,
3901
+ 'data-lowcache': isLowCacheHit(entry.row) ? 'true' : undefined,
3609
3902
  role: 'row',
3610
3903
  },
3611
3904
  /*
@@ -3689,7 +3982,17 @@ window.__ModuleLoader__.load({
3689
3982
  ),
3690
3983
  h(
3691
3984
  'span',
3692
- { className: 'dsh-rum-col', role: 'cell' },
3985
+ {
3986
+ className: 'dsh-rum-col',
3987
+ role: 'cell',
3988
+ /*
3989
+ * The hint explains how much of the request was tool time. The figure
3990
+ * in the cell deliberately excludes it, which is exactly why the two
3991
+ * need to be shown together.
3992
+ */
3993
+ onMouseEnter: (event) => hint.show(toolHintText(entry.row, t), event),
3994
+ onMouseLeave: hint.hide,
3995
+ },
3693
3996
  secondsText(
3694
3997
  /*
3695
3998
  * A running compaction counts up from its own start, exactly as the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recent-usage-metrics",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Geek level for the Performance & usage setting: a live reading of the newest request (first-token latency, speed, tokens, total, amortised) plus a drawer listing the last ten, all derived from the shipped Statistics view data",
5
5
  "repository": {
6
6
  "type": "git",