diff --git a/README.md b/README.md index be06a89..9331bd3 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Open any session and click the **Context / 上下文** tab: | Card | The question it answers | | --- | --- | -| **Context Stats** | Turns, steps, live tool calls & images, context-event tallies — plus a list-price cost estimate (hover the `?` for per-1M rates). | +| **Context Stats** | Turns, steps, live tool calls, the session's cache-hit rate — plus a list-price cost estimate (hover the `?` for per-1M rates). | | **Token Stats** | Where the billed tokens went: cache read/write, uncached input, output — around the cache-hit ring. | | **Timing Stats** | How active time split across model calls, tool runs, and overhead. | | **Current Context** | What's in the window *right now*. | @@ -102,7 +102,7 @@ Pick **Live (next request)** or any retained step, and browse what that request ![Context Events with a compaction](https://raw.githubusercontent.com/bowenliang123/dsh-context/main/docs/context-events.png) -Every injection, compaction, prune, model switch, and plan-mode toggle — labeled with its producer (instruction file, plugin id, skill name), its net token delta (compactions show what they reclaimed), turn/step, and time. The **Inject / Compact / Prune / Switch / Mode** chips filter the log by kind. +Every injection, compaction, prune, model switch, and plan-mode toggle — labeled with its producer (instruction file, plugin id, skill name), its net token delta (compactions show what they reclaimed), turn/step, and time. The **Inject / Compact / Prune / Switch / Mode** chips filter the log by kind, each carrying its whole-session event tally. ### 📁 File Activity — what the agent did to your files diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index b4bed7c..395539a 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -196,6 +196,13 @@ export function makeContextView( // below the last hook. const requests = data ? data.requests : [] const events = data ? data.events : [] + // The stats board's count figures — the split head's precomputed tallies, or + // the collections' own derivation on the inline generation. Shared by the + // stats card's shape cells and the events card's kind-filter counts. + const counts = data?.counts ?? countsOfRecords(requests, events) + // The per-kind tallies that ride the filter buttons: only the three priced + // kinds the host counts (model/mode switches carry no tally, hence undefined). + const kindCounts: Record = { inject: counts.injects, compaction: counts.compactions, prune: counts.prunes } const shownEvents = pickedKinds.length === EVENT_KINDS.length ? events : events.filter(e => pickedKinds.includes(e.kind)) // Per-step bars, or one per turn (each turn's LAST step's record); memoized so hover-driven re-renders keep bar props identity-stable — // the chart's memoized bars then skip reconciliation (turn-mode aggregation allocates). @@ -506,7 +513,7 @@ export function makeContextView( stacks the pair in the narrow pane. */} {inSidebar ? null : (
-
@@ -533,13 +540,20 @@ export function makeContextView(
{t('events.title')}
- {EVENT_KINDS.map(k => ( - - ))} + {EVENT_KINDS.map((k) => { + const n = kindCounts[k] + return ( + + ) + })}
diff --git a/src/client/components/statsContext.tsx b/src/client/components/statsContext.tsx index 34f6b37..4a446a2 100644 --- a/src/client/components/statsContext.tsx +++ b/src/client/components/statsContext.tsx @@ -1,15 +1,15 @@ /** - * The Context card: what the session's context IS and how it evolved — an - * eight-cell 2×4 grid pairing the session's shape (turns / steps / live tool - * calls / the whole-session cache-hit rate) with the context-event tally - * (injections / compactions / prunes) and the whole-session cost estimate. + * The Context card: what the session's context IS and how it evolved — a + * five-cell grid of the session's shape (turns / steps / live tool calls), + * the whole-session cache-hit rate, and the whole-session cost estimate. * Count figures only: nothing here is part of a spendable whole, so no pie — - * proportions live in the composition card. The cache-hit cell reads the - * official `tokenUsage` projection — the same source and formula as the - * harness chat stats line under the composer, shown with one decimal — and - * dashes until a provider reports usage. The cost cell prices the - * host-folded cumulative billed totals (complete session log, never trimmed) - * at the hardcoded DeepSeek V4 list prices (cost.ts) in the locale's + * proportions live in the composition card, and the context-event tallies + * live on the events card's kind filters (contextView.tsx). The cache-hit + * cell reads the official `tokenUsage` projection — the same source and + * formula as the harness chat stats line under the composer, shown with one + * decimal — and dashes until a provider reports usage. The cost cell prices + * the host-folded cumulative billed totals (complete session log, never + * trimmed) at the hardcoded DeepSeek V4 list prices (cost.ts) in the locale's * currency; its hover bubble (a '?' marker + styled DOM tip) explains the * whole-session estimate and lists the per-1M-token table straight from * cost.ts, so printed rates can never drift from the math. @@ -49,7 +49,7 @@ export function countsOfRecords(requests: readonly RequestRecord[], events: read } export function makeStatsContext(kit: ViewKit): (props: { - /** The session-shape and event tallies (host-precomputed on the split generation). */ + /** The session-shape tally (host-precomputed on the split generation). */ counts: TimelineCounts /** Tool calls with a result live in the current context (absent on older hosts). */ toolCalls?: number @@ -112,9 +112,6 @@ export function makeStatsContext(kit: ViewKit): (props: { {cell(t('stats.toolCalls'), props.toolCalls ?? 0)} {cell(t('stats.cacheHit'), hit === null ? '—' : `${hit}%`)} {cell(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip)} - {cell(t('stats.injects'), props.counts.injects)} - {cell(t('stats.compactions'), props.counts.compactions)} - {cell(t('stats.prunes'), props.counts.prunes)} ) diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 24a1c6d..ead7fac 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -18,9 +18,6 @@ export const DICT_ZH: Record = { 'overview.compactReserve': '自动压缩预留:占用达 {pct}% 窗口时触发压缩,此区域一般不实际占用', 'stats.title': '上下文统计', 'stats.turns': '轮次', 'stats.steps': '步数', - 'stats.injects': '注入', - 'stats.compactions': '压缩', - 'stats.prunes': '剪枝', 'stats.toolCalls': '工具调用', 'stats.cacheHit': '缓存命中', 'stats.cost': '预估费用', @@ -247,9 +244,6 @@ export const DICT_EN: Record = { 'overview.compactReserve': 'Auto-compaction reserve: auto-compaction triggers at {pct}% of the window — this area is normally kept as headroom, not actually used', 'stats.title': 'Context Stats', 'stats.turns': 'Turns', 'stats.steps': 'Steps', - 'stats.injects': 'Injections', - 'stats.compactions': 'Compactions', - 'stats.prunes': 'Prunes', 'stats.toolCalls': 'Tool Calls', 'stats.cacheHit': 'Cache Hit', 'stats.cost': 'Cost', diff --git a/src/client/styles/base.css b/src/client/styles/base.css index 65ac6fd..36e9fe8 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -71,6 +71,11 @@ .lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); } .lc-gran-btn:hover { color: var(--dsw-alias-label-primary); } .lc-gran-on, .lc-gran-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); } +/* The events card's kind filters carry their per-kind event tally: a tabular + count set off from the label, dimmed while the chip is off and inherited + (slightly translucent) on the lit chip. */ +.lc-kind-n { margin-left: 4px; font-variant-numeric: tabular-nums; opacity: 0.65; } +.lc-gran-on .lc-kind-n { opacity: 0.85; } /* Shared tooltip-bubble chrome: pair `.lc-tip` with the owner's own tip class, then position locally (top/bottom anchor, width cap, whitespace, reveal rule). @@ -100,7 +105,7 @@ `min-width: 0` lets the split ignore content. Each row is its own `lc-head-row` query container, so the split folds instead of crushing the cards (issue #48): below 720px of row width (a half under ~350px — where the - donut cards' ring+legend single-line and the context card's 4-column cell + donut cards' ring+legend single-line and the context card's 3-column cell grid both start to cramp) the pair stacks single-column. Cards under that end of the band sit under their own lc-card reflows (stats.css: the ring stacks over its legend, the cell grid folds), so the two-card line is the diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index e11538c..7cd46eb 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -1,15 +1,15 @@ /* * The head band's stat cards — the context/plugin row and the token/timing - * donut row: the context card's eight-cell grid with the cost cell's styled - * tip, the composition overview's big number, and the cards' shared anatomy + * donut row: the context card's count grid with the cost cell's styled tip, + * the composition overview's big number, and the cards' shared anatomy * — section heading, donut, slice list, plugin-info rows. */ -/* The context card's eight count cells: shape stats over event tallies, - 4×2 by default — wide-enough cards lay all eight out in ONE row (the - min-width tier below), narrow cards fold to two columns. */ -.lc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; } +/* The context card's five count cells: the session's shape, the cache-hit + rate, and the cost — 3×2 by default (the cells stay wide enough for the + longest English label), folding to a single row on wide cards. */ +.lc-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .lc-stat { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 6px; display: flex; flex-direction: column; gap: 2px; min-width: 0; } -/* The 8-cell stats grid quarters an equal head-row card, so its cells are narrow: tighter padding plus a corner-pinned '?' (out of the label's line) keep the longest label (预估费用) on one line. */ +/* The 5-cell stats grid shares a half-width head-row card with a sibling, so its cells are moderate: tighter padding plus a corner-pinned '?' (out of the label's line) keep the longest labels on one line. */ .lc-stats .lc-stat { padding: 6px 3px; } .lc-stats .lc-stat-q { position: absolute; top: 3px; right: 4px; margin: 0; } .lc-stat-label { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -104,20 +104,20 @@ .lc-pi-value { min-width: 0; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; } .lc-pi-row-btn .lc-pi-value { font-weight: 400; } /* - Wide head cards: on roomy panes the context card's eight cells fit ONE - line (~82px each at the 700px mark). Degrades silently on engines without + Wide head cards: on roomy panes the context card's five cells fit ONE line + (~130px each at the 700px mark). Degrades silently on engines without container queries. */ @container lc-card (min-width: 700px) { - .lc-stats { grid-template-columns: repeat(8, minmax(0, 1fr)); } + .lc-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); } } /* Narrow head cards: the side-by-side ring + legend reads single-line only above ~320px of card content width. The two-tier rows already absorb the squeeze — the name gives ground before the fixed share column — so the - compact block only tightens the ring gap. The context card's 8-cell grid - folds to 2 columns instead: four ~65px cells would ellipsize the longer - English labels (Compactions, Injections). Below 240px even the ring stops + compact block only tightens the ring gap. The context card's 5-cell grid + folds to 2 columns instead: three ~65px cells would ellipsize the longer + English labels (Tool Calls). Below 240px even the ring stops fitting, so the card stacks: the ring centers over the full-width legend rows. Cards resolve these queries against themselves (the shared `lc-card` container in base.css) at every split of the head band — two-across or diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 7bb2d96..370e3f0 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -490,15 +490,21 @@ describe('ContextView — interactions', () => { test('event-kind filter narrows, unions, drops, and resets', async () => { const m = await mountRich('sv-kinds') const countEvents = () => queryAll(m.container, '.lc-event').length + const kindBtn = (k: string) => query(m.container, `.lc-kinds [data-kind="${k}"]`) as HTMLElement assert.equal(countEvents(), 2) + // The buttons carry their per-kind tallies (richTimeline: 1 inject, 1 compaction, 0 prunes). + assert.deepEqual( + queryAll(m.container, '.lc-kinds .lc-kind-n').map(el => text(el)), + ['1', '1', '0'], + ) - await click(buttonByText(m.container, DICT_EN['kind.inject'])) + await click(kindBtn('inject')) assert.equal(countEvents(), 1) - await click(buttonByText(m.container, DICT_EN['kind.compaction'])) + await click(kindBtn('compaction')) assert.equal(countEvents(), 2) - await click(buttonByText(m.container, DICT_EN['kind.compaction'])) + await click(kindBtn('compaction')) assert.equal(countEvents(), 1) - await click(buttonByText(m.container, DICT_EN['kind.inject'])) + await click(kindBtn('inject')) assert.equal(countEvents(), 2) await m.unmount() }) diff --git a/tests/client/components/statsContext.spec.ts b/tests/client/components/statsContext.spec.ts index 3356413..e546464 100644 --- a/tests/client/components/statsContext.spec.ts +++ b/tests/client/components/statsContext.spec.ts @@ -1,9 +1,9 @@ // StatsContext (src/client/components/statsContext.tsx) rendered with real -// React: the eight-cell 2×4 grid — session shape, the chat-line cache-hit -// cell, the priced cost cell with its rate tooltip, and the context-event -// tally — in both locales. The count figures arrive precomputed (the split -// generation's wire head carries them); `countsOfRecords` is the inline -// generation's derivation, pinned here to the same totals. +// React: the five-cell grid — session shape, the chat-line cache-hit cell, +// and the priced cost cell with its rate tooltip — in both locales. The +// context-event tallies live on the events card's kind filters +// (contextView.spec.ts); `countsOfRecords` still derives every count the +// split generation's wire head carries, pinned here. import { createElement as h } from 'react' import assert from 'node:assert/strict' @@ -57,7 +57,7 @@ describe('countsOfRecords (the inline generation derivation)', () => { }) describe('StatsContext', () => { - test('folds the eight-cell grid: shape stats, the cache-hit cell, cost, and the event tally', async () => { + test('folds the five-cell grid: shape stats, the cache-hit cell, and cost', async () => { const m = await mount(h(StatsContext, { counts: { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1 }, toolCalls: 3, @@ -67,12 +67,9 @@ describe('StatsContext', () => { })) assert.ok(text(m.container).includes('Context Stats')) const { labels, values } = cells(m.container) - assert.equal(labels.length, 8) - assert.deepEqual(labels, [ - 'Turns', 'Steps', 'Tool Calls', 'Cache Hit', - 'Cost?', 'Injections', 'Compactions', 'Prunes', - ]) - assert.deepEqual(values, ['3', '4', '3', '66.6%', '$0.30', '3', '2', '1']) + assert.equal(labels.length, 5) + assert.deepEqual(labels, ['Turns', 'Steps', 'Tool Calls', 'Cache Hit', 'Cost?']) + assert.deepEqual(values, ['3', '4', '3', '66.6%', '$0.30']) await m.unmount() }) @@ -82,7 +79,7 @@ describe('StatsContext', () => { usage: null, locale: 'en', })) - assert.deepEqual(cells(m.container).values, ['0', '0', '0', '—', '—', '0', '0', '0']) + assert.deepEqual(cells(m.container).values, ['0', '0', '0', '—', '—']) await m.unmount() // A usage report with nothing billed prompt-side dashes the hit too. const zero: TokenUsage = { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } @@ -91,7 +88,7 @@ describe('StatsContext', () => { usage: zero, locale: 'en', })) - assert.deepEqual(cells(m2.container).values, ['0', '0', '0', '—', '—', '0', '0', '0']) + assert.deepEqual(cells(m2.container).values, ['0', '0', '0', '—', '—']) await m2.unmount() }) @@ -122,8 +119,8 @@ describe('StatsContext', () => { })) assert.ok(text(m.container).includes('上下文统计')) const { labels, values } = cells(m.container) - assert.deepEqual(labels, ['轮次', '步数', '工具调用', '缓存命中', '预估费用?', '注入', '压缩', '剪枝']) - assert.deepEqual(values, ['1', '1', '0', '66.6%', '¥2.00', '0', '1', '0']) + assert.deepEqual(labels, ['轮次', '步数', '工具调用', '缓存命中', '预估费用?']) + assert.deepEqual(values, ['1', '1', '0', '66.6%', '¥2.00']) await m.unmount() }) })