diff --git a/src/client/components/requestDetail.tsx b/src/client/components/requestDetail.tsx
index fb991dd..1b70503 100644
--- a/src/client/components/requestDetail.tsx
+++ b/src/client/components/requestDetail.tsx
@@ -1,6 +1,7 @@
import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import { partsOf, CATS } from '../categories'
+import { cacheHitPercent } from '../format'
import type { StackedBarProps } from './stackedBar'
import type { ViewKit } from '../viewkit'
@@ -37,13 +38,17 @@ export function makeRequestDetail(
: null}
{isTurn ? {t('detail.lastStep')} : null}
{fmtTime(req.time)}
- {t('detail.estTotal', { n: fmt(req.total) })}
{req.prompt !== undefined
? {t('detail.actual', { n: fmt(req.prompt) })}
: null}
{req.output !== undefined
? {t('detail.output', { n: fmt(req.output) })}
: null}
+ {/* Step-level cache hit: the cache-served half of the billed prompt; absent on hosts
+ that do not fold `cacheRead` (and on usage-less requests) — the figure drops out. */}
+ {req.prompt !== undefined && req.cacheRead !== undefined
+ ? {t('detail.cache', { n: cacheHitPercent(req.cacheRead, req.prompt) ?? '—' })}
+ : null}
diff --git a/src/client/components/statsBoard.tsx b/src/client/components/statsBoard.tsx
index f87b56d..29bd336 100644
--- a/src/client/components/statsBoard.tsx
+++ b/src/client/components/statsBoard.tsx
@@ -8,25 +8,22 @@ import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord, SessionCostUsage, TokenUsage } from '../../shared/types'
import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../cost'
import type { CostCurrency } from '../cost'
+import { cacheHitPercent as cacheHitPercentOf } from '../format'
import { numOf } from '../services'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
/**
- * Cache-hit share of billed prompt-side input — same three disjoint buckets as the harness chat line (`cacheReadTokens` over uncached +
- * reads + writes), but deliberately TRUNCATES to two decimals (cut, not round), unlike that line's integer rounding; null when nothing was
- * billed. The 1e-9 epsilon absorbs only float noise (integer token counts never sit that close to a boundary); `numOf` keeps malformed
- * payloads at a dash.
+ * Cache-hit share of billed prompt-side input — same three disjoint buckets
+ * as the harness chat line (`cacheReadTokens` over uncached + reads + writes)
+ * and the step line's 缓存 figure; null when nothing was billed.
*/
function cacheHitPercent(usage: TokenUsage): string | null {
const uncached = numOf(usage.uncachedInputTokens)
const reads = numOf(usage.cacheReadTokens)
const writes = numOf(usage.cacheWriteTokens)
- const billed = uncached + reads + writes
- if (billed === 0) return null
- const hundredths = Math.trunc((reads / billed) * 10000 + 1e-9)
- return `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}`
+ return cacheHitPercentOf(reads, uncached + reads + writes)
}
export function makeStatsBoard(kit: ViewKit): (props: {
diff --git a/src/client/format.ts b/src/client/format.ts
index 17ce0e5..5baef43 100644
--- a/src/client/format.ts
+++ b/src/client/format.ts
@@ -17,6 +17,19 @@ export function fmtBytes(n: number | null | undefined): string {
return String(Math.round(n)) + ' B'
}
+/**
+ * Cache-hit share of billed prompt-side input (`reads` over `billed`),
+ * TRUNCATED to two decimals (cut, not round) — same formula as the harness
+ * chat stats line's '缓存命中' figure and the stats board's cell. Null when
+ * nothing was billed. The 1e-9 epsilon absorbs only float noise (integer
+ * token counts never sit that close to a boundary).
+ */
+export function cacheHitPercent(reads: number, billed: number): string | null {
+ if (!(billed > 0)) return null
+ const hundredths = Math.trunc((reads / billed) * 10000 + 1e-9)
+ return `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}`
+}
+
export function fmtTime(t: number): string {
// en-GB 24-hour clock zero-pads HH:MM:SS without a helper; invalid dates must show '—' (toLocaleTimeString throws RangeError).
const d = new Date(t)
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index e68979b..5508d89 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -51,6 +51,7 @@ export const DICT_ZH: Record = {
'detail.estTotal': '估算合计 ≈ {n}',
'detail.actual': '实际 prompt {n}',
'detail.output': '输出 {n}',
+ 'detail.cache': '缓存 {n}%',
'events.title': '上下文事件',
'events.empty': '暂无上下文事件(压缩、注入、模型切换会出现在这里)',
'events.at': '第 {t} 轮 · 第 {s} 步',
@@ -174,6 +175,7 @@ export const DICT_EN: Record = {
'detail.estTotal': 'Estimated ≈ {n}',
'detail.actual': 'Actual Prompt {n}',
'detail.output': 'Output {n}',
+ 'detail.cache': 'Cache {n}%',
'events.title': 'Context Events',
'events.empty': 'No context events yet (compaction, injections, model switches appear here)',
'events.at': 'Turn {t} · Step {s}',
diff --git a/src/client/styles.css b/src/client/styles.css
index c7cc1ae..839882c 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -132,6 +132,7 @@
.lc-detail-head b { color: var(--dsw-alias-label-primary); }
.lc-detail-marker { color: var(--dsw-alias-state-warn-primary); font-size: 11px; background: var(--dsw-alias-bg-layer-2); border-radius: 6px; padding: 1px 7px; }
.lc-detail-head .lc-actual { color: var(--dsw-alias-state-success-primary); }
+.lc-detail-head .lc-cache { color: var(--dsw-alias-state-success-primary); }
.lc-detail-tag { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); }
.lc-detail-rows { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
.lc-detail-row { display: flex; align-items: center; gap: 8px; }
diff --git a/src/host/fold.ts b/src/host/fold.ts
index 39aa282..f7661da 100644
--- a/src/host/fold.ts
+++ b/src/host/fold.ts
@@ -603,6 +603,9 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// already includes reasoningTokens. No separate prompt/output field
// exists in the durable vocabulary.
record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0)
+ // Cache-hit share of the billed prompt (the step line's 缓存 figure):
+ // keep the cache-served half of `prompt`; absent = no cache buckets.
+ if (typeof usage.cacheReadTokens === 'number') record.cacheRead = usage.cacheReadTokens
if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens
accumulateCost(s, event.time, usage)
}
diff --git a/src/host/timeline.ts b/src/host/timeline.ts
index fe98351..186a470 100644
--- a/src/host/timeline.ts
+++ b/src/host/timeline.ts
@@ -56,6 +56,7 @@ const requestRecordSchema = z.object({
tool: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
prompt: z.number().int().nonnegative().optional(),
+ cacheRead: z.number().int().nonnegative().optional(),
output: z.number().int().nonnegative().optional(),
stepCount: z.number().int().positive().optional(),
}).strict()
@@ -192,7 +193,8 @@ export function createContextTimelineDefinition(config: Config): ProjectionDefin
// 6: image blocks reprice via the official vision calculator instead of the meter's generic JSON branch; cached rows refolded.
// 7: the whole-session image count (`images`) joined the state; cached rows refolded.
// 8: the image count moved to per-node `imgs` (live-surface cell); cached rows refolded.
- stateVersion: 8,
+ // 9: per-request billed cache-read tokens (`cacheRead`) joined request records; cached rows refolded.
+ stateVersion: 9,
}
return definition
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index c330791..954879b 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -237,6 +237,12 @@ export interface RequestRecord {
tool: number
total: number
prompt?: number
+ /**
+ * Billed cache-read (served) prompt tokens of this request — the
+ * hit-rate numerator against `prompt` (input + cacheRead + cacheWrite).
+ * Absent on older hosts / usage-less requests; zero is a real value.
+ */
+ cacheRead?: number
output?: number
/**
* Turn-mode aggregate marker, set by the Client's aggregateByTurn (one bar
diff --git a/tests/chart.spec.ts b/tests/chart.spec.ts
index 22c7faf..29b6ee7 100644
--- a/tests/chart.spec.ts
+++ b/tests/chart.spec.ts
@@ -60,11 +60,15 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
let tr = renderView()
assert.match(detailStep(tr), /Turn 3/, 'detail defaults to the newest request (Turn 3)')
+ assert.match(detailStep(tr), /Actual Prompt 83\.0k/, 'detail shows the billed prompt of the newest request')
+ assert.match(detailStep(tr), /Cache 99\.39%/, 'step-level cache hit = cacheRead / billed prompt, two decimals (truncated)')
+ assert.ok(!detailStep(tr).includes('Estimated') && !detailStep(tr).includes('estTotal'), 'the heuristic estimate line is gone from the step detail')
assert.equal(byClass(tr, 'lc-bar-hovered').length, 0, 'no hovered bar initially')
ctxSlots[1][1](3)
tr = renderView()
assert.match(detailStep(tr), /Turn 2/, 'hovering a bar links the detail below to it')
+ assert.ok(!detailStep(tr).includes('Cache'), 'a usage-less request shows no cache figure')
const hovered = byClass(tr, 'lc-bar-hovered')
assert.equal(hovered.length, 1, 'exactly one hovered bar')
assert.equal(hovered[0].args[1]['data-seq'], 3, 'hovered bar is seq 3')
diff --git a/tests/helpers/viewBed.ts b/tests/helpers/viewBed.ts
index 65e422b..ffd4ca2 100644
--- a/tests/helpers/viewBed.ts
+++ b/tests/helpers/viewBed.ts
@@ -57,7 +57,7 @@ export const FAKE_PRIMITIVES = {
MarkdownText: (p) => ({ kind: 'element', args: ['div', { className: 'lc-md-stub' }, p.text] }),
}
-export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'form.notice': 'Notice', 'ev.mode.plan.on': 'Plan mode on', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
+export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'detail.actual': 'Actual Prompt {n}', 'detail.output': 'Output {n}', 'detail.cache': 'Cache {n}%', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'form.notice': 'Notice', 'ev.mode.plan.on': 'Plan mode on', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'block.line': '1 line', 'block.lines': '{n} lines', 'call.ok': 'OK', 'call.fail': 'Failed', 'call.exit': 'exit {n}', 'node.failed': 'Tool execution failed', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.preview': 'Image preview', 'attach.close': 'Close', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.raw': 'Raw', 'attach.sent': 'Sent', 'attach.token': 'Token', 'attach.tokensTip': 'estimated tokens', 'settings.title': 'Context', 'settings.desc': 'Display preferences for the Context panel', 'settings.gran': 'Default trend granularity', 'settings.mode': 'Default trend display', 'settings.expand': 'Expand', 'settings.collapse': 'Collapse', 'settings.readOnly': 'Settings are read-only in this environment', 'gran.total': 'Total', 'gran.diff': 'Diff' }
/** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */
@@ -68,7 +68,7 @@ export const snapshot = {
{ seq: 1, turn: 1, step: 0, time: 1000, system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100, prompt: 95 },
{ seq: 2, turn: 1, step: 1, time: 2000, system: 10, tools: 20, user: 25, inject: 5, assistant: 10, tool: 20, total: 90 },
{ seq: 3, turn: 2, step: 0, time: 3000, system: 10, tools: 20, user: 40, inject: 5, assistant: 12, tool: 20, total: 107 },
- { seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000 },
+ { seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000, cacheRead: 82499 },
],
events: [], nodes: [
{ seq: 1, cat: 'user', tokens: 10, text: 'first message', time: 1000 },
diff --git a/tests/host.spec.ts b/tests/host.spec.ts
index e3bbe60..c1dd723 100644
--- a/tests/host.spec.ts
+++ b/tests/host.spec.ts
@@ -401,6 +401,7 @@ test('host half: projection unit, fold semantics, attribution, retention, determ
{ seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30, reasoningTokens: 10 }, message: { content: [] } } },
])
assert.equal(usageLog.requests[0].prompt, 1000, 'prompt side sums the disjoint input buckets')
+ assert.equal(usageLog.requests[0].cacheRead, 150, 'cache-served prompt tokens ride the request record (hit-rate numerator)')
assert.equal(usageLog.requests[0].output, 30, 'output is outputTokens (reasoning already inside)')
assert.equal(usageLog.cost, undefined, 'no model name -> nothing priced into the cost totals')
assert.equal(v.cost, undefined, 'a non-flash/pro model (deepseek-v4) is not priced')
@@ -479,6 +480,23 @@ test('host half: projection unit, fold semantics, attribution, retention, determ
// stay serializable.
const armed = def.apply(def.init(), { seq: 1, type: 'compaction/summary', time: 1000, data: { shadowedSeqs: [2], shadowedTokenCount: 10 } })
assert.ok(snapshotJsonValue(armed) !== undefined, 'armed pendingShadowedSeqs state is still plain JSON')
+ // `cacheRead` follows the same absence rule: present only when the provider
+ // reported cache-served tokens (a real 0 included); usage-less requests
+ // never materialize an `undefined`-valued property.
+ {
+ let st = def.init()
+ for (const ev of [
+ { seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 100, cacheReadTokens: 900 }, message: { content: [] } } },
+ { seq: 2, type: 'assistant/message', time: 2000, data: { turn: 1, step: 2, message: { content: [] } } },
+ ]) st = def.apply(st, ev)
+ assert.equal(st.requests[0].cacheRead, 900, 'cacheRead folds from cacheReadTokens')
+ assert.ok(!('cacheRead' in st.requests[1]), 'usage-less requests keep cacheRead absent (plain-JSON precondition)')
+ assert.ok(snapshotJsonValue(st) !== undefined, 'cacheRead-carrying state stays losslessly serializable')
+ assert.equal(def.stateSchema.parse(st).requests[0].cacheRead, 900, 'stateSchema round-trips cacheRead')
+ const view = def.view(st)
+ assert.equal(view.requests[0].cacheRead, 900, 'cacheRead rides the wire view')
+ assert.ok(!('cacheRead' in view.requests[1]), 'wire view keeps cacheRead absent')
+ }
// -- dual-contract compatibility (dsh 0.1.1-rc.1+): each unit carries the
// NEW contract fields alongside the old ones. `stateSchema` validates the