dsh-token-total 1.0.1 → 1.0.3

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.
@@ -0,0 +1,130 @@
1
+ /* Mirror the host ContextMeter, including its theme and font-size tokens. */
2
+ .root {
3
+ flex: none;
4
+ display: inline-flex;
5
+ }
6
+
7
+ .trigger {
8
+ border-radius: var(--dsw-radius-sm);
9
+ color: var(--dsw-alias-label-tertiary);
10
+ font-family: inherit;
11
+ font-size: var(--dsh-content-font-size-secondary, 13px);
12
+ font-variant-numeric: tabular-nums;
13
+ line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
14
+ white-space: nowrap;
15
+ cursor: pointer;
16
+ background: transparent;
17
+ border: none;
18
+ flex: none;
19
+ align-items: center;
20
+ gap: 6px;
21
+ padding: 1px 8px;
22
+ display: inline-flex;
23
+ }
24
+
25
+ .trigger:hover,
26
+ .trigger[aria-expanded='true'] {
27
+ background: var(--dsw-alias-interactive-bg-hover);
28
+ color: var(--dsw-alias-label-secondary);
29
+ }
30
+
31
+ .track {
32
+ fill: none;
33
+ stroke: var(--dsw-alias-border-l3);
34
+ stroke-width: 2px;
35
+ }
36
+
37
+ .fill {
38
+ fill: none;
39
+ stroke: var(--dsw-alias-label-tertiary);
40
+ stroke-width: 2px;
41
+ stroke-linecap: round;
42
+ }
43
+
44
+ .panel {
45
+ z-index: 1100;
46
+ box-sizing: border-box;
47
+ border-radius: var(--dsw-radius-lg);
48
+ background: var(--dsw-specific-menu);
49
+ width: min(264px, calc(100vw - 24px));
50
+ backdrop-filter: var(--dsw-menu-backdrop-filter);
51
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
52
+ box-shadow: var(--dsw-elevation-prominent);
53
+ color: var(--dsw-alias-label-secondary);
54
+ cursor: default;
55
+ border: 0;
56
+ padding: 12px;
57
+ font-size: 12px;
58
+ line-height: 20px;
59
+ position: fixed;
60
+ }
61
+
62
+ .header {
63
+ align-items: center;
64
+ gap: 6px;
65
+ display: flex;
66
+ }
67
+
68
+ .figures {
69
+ font-variant-numeric: tabular-nums;
70
+ color: var(--dsw-alias-label-primary);
71
+ margin-left: auto;
72
+ font-weight: 500;
73
+ }
74
+
75
+ .percent {
76
+ color: var(--dsw-alias-label-primary);
77
+ font-weight: 500;
78
+ }
79
+
80
+ .headline { color: var(--dsw-alias-label-tertiary); }
81
+ .headline:empty { display: none; }
82
+
83
+ .bar {
84
+ corner-shape: round;
85
+ background: var(--dsw-alias-interactive-bg-hover);
86
+ border-radius: 999px;
87
+ gap: 1px;
88
+ height: 4px;
89
+ margin: 10px 0 12px;
90
+ display: flex;
91
+ overflow: hidden;
92
+ }
93
+
94
+ .segment {
95
+ background: var(--meter-tint, var(--dsw-alias-label-tertiary));
96
+ border-radius: 1px;
97
+ flex: none;
98
+ min-width: 2px;
99
+ height: 100%;
100
+ }
101
+
102
+ .swatch {
103
+ background: var(--meter-tint);
104
+ vertical-align: baseline;
105
+ border-radius: 2px;
106
+ width: 8px;
107
+ height: 8px;
108
+ margin-right: 6px;
109
+ display: inline-block;
110
+ }
111
+
112
+ .colorSystem { --meter-tint: var(--dsw-static-neutral-bluish-400); }
113
+ .colorTools { --meter-tint: #a78bfa; }
114
+ .colorMessages { --meter-tint: var(--dsw-static-blue-450); }
115
+ .rows { margin: 6px 0 0; }
116
+
117
+ .row {
118
+ justify-content: space-between;
119
+ align-items: center;
120
+ gap: 12px;
121
+ padding: 2px 0;
122
+ display: flex;
123
+ }
124
+
125
+ .row dt { color: var(--dsw-alias-label-secondary); }
126
+ .row dd {
127
+ font-variant-numeric: tabular-nums;
128
+ color: var(--dsw-alias-label-primary);
129
+ margin: 0;
130
+ }
@@ -0,0 +1,93 @@
1
+ import { createPortal } from 'react-dom'
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import { MEASURE_STYLE } from './stat-dialog.ts'
6
+ import { contextBreakdownSegments, contextOccupancy, formatContextTokens, type ContextBreakdownLike, type ContextPressureLike } from './context-meter-core.ts'
7
+ import css from './ContextUsageEntry.module.css'
8
+
9
+ export const CONTEXT_LOCALE_NS = 'tokenTotalContext'
10
+ const keys = ['label', 'used', 'system', 'tools', 'messages', 'aria', 'thousand', 'million'] as const
11
+ export type ContextLocaleKey = typeof keys[number]
12
+ export const CONTEXT_LOCALES: Record<string, Record<ContextLocaleKey, string>> = {
13
+ zh: { label: '上下文', used: '上下文已用', system: '系统提示词', tools: '工具定义', messages: '对话消息', aria: '上下文已用 {percent}', thousand: '{value}K', million: '{value}M' },
14
+ en: { label: 'Context', used: 'Context used', system: 'System', tools: 'Tools', messages: 'Conversation', aria: 'Context usage {percent}', thousand: '{value}K', million: '{value}M' },
15
+ 'zh-TW': { label: '上下文', used: '已用上下文', system: '系統提示詞', tools: '工具', messages: '對話', aria: '上下文使用量 {percent}', thousand: '{value}千', million: '{value}百萬' },
16
+ }
17
+
18
+ type ProjectionMap = {
19
+ contextPressure: ContextPressureLike
20
+ contextBreakdown: ContextBreakdownLike
21
+ }
22
+ type UseContextProjection = <K extends keyof ProjectionMap & string>(key: K) => ProjectionMap[K] | undefined
23
+
24
+ export interface ContextUsageEntryProps extends PropsLocale<typeof CONTEXT_LOCALE_NS> {
25
+ useProjection: UseContextProjection
26
+ installContextTakeover?: (anchor: HTMLElement) => () => void
27
+ }
28
+
29
+ export function ContextUsageEntry({ useProjection, t, installContextTakeover }: ContextUsageEntryProps): JSX.Element | null {
30
+ const pressure = useProjection('contextPressure')
31
+ const breakdown = useProjection('contextBreakdown')
32
+ const context = contextOccupancy(pressure)
33
+ const [open, setOpen] = useState(false)
34
+ const rootRef = useRef<HTMLSpanElement | null>(null)
35
+ const panelRef = useRef<HTMLDivElement | null>(null)
36
+ const position = useAnchoredPosition({ open: open && context !== null, anchorRef: rootRef, panelRef, side: 'top', gap: 8, margin: 12 })
37
+ useDismissOnOutsidePointer(rootRef, open && context !== null, setOpen, panelRef)
38
+ useLayoutEffect(() => {
39
+ const anchor = rootRef.current
40
+ if (anchor === null || context === null || installContextTakeover === undefined) return
41
+ return installContextTakeover(anchor)
42
+ }, [context !== null, installContextTakeover])
43
+ useEffect(() => { if (context === null && open) setOpen(false) }, [context, open])
44
+ useEffect(() => {
45
+ if (!open || context === null) return
46
+ const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
47
+ document.addEventListener('keydown', onKeyDown)
48
+ return () => document.removeEventListener('keydown', onKeyDown)
49
+ }, [context, open])
50
+ if (context === null) return null
51
+ const percent = context.percent
52
+ const reading = `${percent}%`
53
+ const segments = contextBreakdownSegments(percent, breakdown)
54
+ const format = (value: number): string => formatContextTokens(value, {
55
+ thousand: t('thousand'), million: t('million'),
56
+ })
57
+ const [headBefore = '', headAfter = ''] = t('aria', { percent: '\0' }).split('\0').map(part => part.trim())
58
+ const rowLabels = ['system', 'tools', 'messages'] as const
59
+ const rowColors = ['colorSystem', 'colorTools', 'colorMessages'] as const
60
+ const segmentColors: Record<string, string> = { systemTokens: css.colorSystem, toolsTokens: css.colorTools, messageTokens: css.colorMessages }
61
+ return (
62
+ <span ref={rootRef} className={css.root} data-dsh-token-total-stat="context">
63
+ <button type="button" className={css.trigger} aria-label={t('aria', { percent: reading })} aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(!open)}>
64
+ <svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true">
65
+ <circle className={css.track} cx="7" cy="7" r="5.5" />
66
+ <circle className={css.fill} cx="7" cy="7" r="5.5" strokeDasharray={`${2 * Math.PI * 5.5 * percent / 100} ${2 * Math.PI * 5.5}`} transform="rotate(-90 7 7)" />
67
+ </svg>
68
+ <span>{reading}</span>
69
+ </button>
70
+ {open && createPortal(
71
+ <div ref={panelRef} className={css.panel} style={position ?? MEASURE_STYLE} role="dialog" aria-label={t('used')}>
72
+ <div className={css.header}>
73
+ <span className={css.headline}>{headBefore}</span>
74
+ <span className={css.percent}>{reading}</span>
75
+ <span className={css.headline}>{headAfter}</span>
76
+ <span className={css.figures}>{`~${format(context.usedTokens)} / ${format(context.contextWindow)}`}</span>
77
+ </div>
78
+ <div className={css.bar}>{segments.map(segment => <div key={segment.key} className={segmentColors[segment.key] === undefined ? css.segment : `${css.segment} ${segmentColors[segment.key]}`} style={{ width: `${segment.width}%` }} />)}</div>
79
+ {breakdown !== undefined && <dl className={css.rows}>
80
+ {(['systemTokens', 'toolsTokens', 'messageTokens'] as const).map((key, index) => <div className={css.row} key={key}>
81
+ <dt><span className={`${css.swatch} ${css[rowColors[index]]}`} />{t(rowLabels[index])}</dt>
82
+ <dd>{`~${format(breakdown[key])}`}</dd>
83
+ </div>)}
84
+ </dl>}
85
+ </div>, document.body,
86
+ )}
87
+ </span>
88
+ )
89
+ }
90
+
91
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
92
+ interface LocaleNamespaceMap { tokenTotalContext: ContextLocaleKey }
93
+ }
@@ -53,7 +53,7 @@
53
53
  declarations from `.root` onto `.anchor`. The tier stays off the plain
54
54
  `.anchor` because the pre-split row must keep its own `.root` tier. */
55
55
  .dockAnchor {
56
- font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
56
+ font-size: var(--dsh-content-font-size-secondary, 13px);
57
57
  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
58
58
  }
59
59
 
@@ -65,8 +65,8 @@
65
65
  max-width: 100%;
66
66
  padding: 1px 8px;
67
67
  border: none;
68
- border-radius: 24px;
69
- background: transparent;
68
+ border-radius: var(--dsw-radius-sm);
69
+ background: none;
70
70
  color: var(--dsw-alias-label-tertiary);
71
71
  font: inherit;
72
72
  font-variant-numeric: tabular-nums;
@@ -103,22 +103,13 @@ button.pill[aria-expanded='true'] {
103
103
  margin: 0 6px;
104
104
  }
105
105
 
106
- /* Reserved slot for the speed figure: the official pill's figure only changes
107
- when a step settles, but the live reading refreshes on every beat. A width
108
- that tracked the digits would reflow the centred row on each of them and drag
109
- the counts pill beside it along; 10ch covers "1200 tok/s", the widest shape
110
- formatTokensPerSecond can produce. */
106
+ /* The live throughput reading sits inline exactly like the official figure:
107
+ no reserved width, no box of its own. The earlier `min-width: 10ch` slot kept
108
+ the centred row from shifting while the number grew, but it also left a
109
+ permanent gap before the separator — the official pill has no such slot, and
110
+ native parity wins over that jitter fix. */
111
111
  .speed {
112
- display: inline-block;
113
- min-width: 10ch;
114
- }
115
-
116
- /* Marks a usage figure that now includes subagent spend. Deliberately quiet:
117
- it rides inside the official pill's label and must not read as a badge or
118
- compete with the token count beside it. */
119
- .subagentMark {
120
- color: var(--dsw-alias-label-tertiary);
121
- font-variant-numeric: tabular-nums;
112
+ display: inline;
122
113
  }
123
114
 
124
115
  /* Portal dialog surface: fixed in the viewport, left/top supplied inline from
@@ -135,15 +126,15 @@ button.pill[aria-expanded='true'] {
135
126
  width: max-content;
136
127
  min-width: min(300px, calc(100vw - 24px));
137
128
  max-width: min(440px, calc(100vw - 24px));
138
- padding: 16px;
129
+ padding: 12px;
139
130
  border: 0;
140
- border-radius: 12px;
131
+ border-radius: var(--dsw-radius-lg);
141
132
  background: var(--dsw-specific-menu);
142
133
  backdrop-filter: var(--dsw-menu-backdrop-filter);
143
134
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
144
135
  box-shadow: var(--dsw-elevation-prominent);
145
- font-size: 12px;
146
- line-height: 18px;
136
+ font-size: var(--dsh-content-font-size-secondary, 13px);
137
+ line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
147
138
  color: var(--dsw-alias-label-secondary);
148
139
  cursor: default;
149
140
  }
@@ -160,7 +151,7 @@ button.pill[aria-expanded='true'] {
160
151
 
161
152
  /* Rule under the heading, above its rows. */
162
153
  .dialogTitleRule {
163
- margin-bottom: 10px;
154
+ margin-bottom: 8px;
164
155
  border-top: 0.5px solid var(--dsw-alias-border-l2);
165
156
  }
166
157
 
@@ -185,7 +176,7 @@ button.pill[aria-expanded='true'] {
185
176
  .dialogDetails {
186
177
  display: grid;
187
178
  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
188
- gap: 6px 16px;
179
+ gap: 4px 12px;
189
180
  margin: 0;
190
181
  color: var(--dsw-alias-label-tertiary);
191
182
  }
@@ -23,9 +23,12 @@
23
23
  * Which pair to register is read from the dock's entry ledger in
24
24
  * src/client/index.tsx.
25
25
  *
26
- * Cache-hit precision, the full session token breakdown and streaming
27
- * throughput are always enabled; no stored preferences gate these readouts.
28
- * The token dialog includes the cache-miss row alongside every billing bucket.
26
+ * Streaming throughput is always enabled; no stored preference gates it. The
27
+ * three columns are style-for-style the official ones: same tier, radius,
28
+ * colours, same dialog rows and the same integer cache-hit figure. The one
29
+ * difference is the middle figure's SCOPE — it totals the whole session tree
30
+ * (this agent plus every subagent it spawned), which the native projection
31
+ * cannot see.
29
32
  *
30
33
  * Streaming throughput replaces the gauge pill's speed
31
34
  * figure with a live estimate while a step is still streaming and drops back to
@@ -44,14 +47,14 @@
44
47
  */
45
48
  import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
46
49
  import { createPortal } from 'react-dom'
47
- import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
48
50
  import type { PropsLocale, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
51
+ import { DatabaseIcon, GaugeIcon } from './stats-icons.tsx'
49
52
  import { LOCALE_NS } from './locales.ts'
50
53
  import { MEASURE_STYLE, useStatDialog, type StatDialogSeat } from './stat-dialog.ts'
51
54
  import {
52
- billedInputTokens, cacheHitPercentPrecise, deriveStats,
55
+ billedInputTokens, cacheHitPercent, deriveStats,
53
56
  estimateOutputTokens, formatDuration, formatTokensCompact, formatTokensPerSecond,
54
- measuredTokenScale, StreamingRateSampler, tokenBreakdown,
57
+ measuredTokenScale, StreamingRateSampler,
55
58
  type ConversationNodeLike, type PartialAssistantLike, type UseProjection, type WindowStats,
56
59
  } from './stats-line-core.ts'
57
60
  import { formatTokens } from './format.ts'
@@ -112,18 +115,15 @@ function anchorClass(dock: boolean): string | undefined {
112
115
  return dock ? `${css.anchor} ${css.dockAnchor}` : css.anchor
113
116
  }
114
117
 
115
- /** The separator + figure shape both speed arms render into the gauge pill.
116
- * The figure sits in a width-reserved slot (css.speed): the live reading
117
- * changes every second, and a slot that tracked its digits would reflow the
118
- * centred row — and with it the counts pill — on every refresh. The slot is
119
- * rendered even with no figure at all (an empty session has no official figure
120
- * to fall back to until its first step settles), so the row's layout never
121
- * depends on whether a number is available. */
118
+ /** The separator + figure shape both speed arms render into the gauge pill:
119
+ * exactly the official pill's inline shape — separator and figure appear only
120
+ * when there is a figure, and the figure occupies no reserved width, so the
121
+ * row reads and reflows like the native one. */
122
122
  function SpeedFigure({ text }: { text: string | null }) {
123
123
  return (
124
124
  <>
125
125
  {text !== null && <span className={css.sep} aria-hidden>·</span>}
126
- <span className={css.speed} data-stats-speed>{text}</span>
126
+ {text !== null && <span className={css.speed} data-stats-speed>{text}</span>}
127
127
  </>
128
128
  )
129
129
  }
@@ -290,14 +290,14 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
290
290
  return (
291
291
  <span className={anchorClass(dock)}>
292
292
  <span className={css.pill}>
293
- <IconGaugeOutline16 />
293
+ <GaugeIcon />
294
294
  {label}
295
295
  </span>
296
296
  </span>
297
297
  )
298
298
  }
299
299
  return (
300
- <span ref={rootRef} className={anchorClass(dock)}>
300
+ <span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
301
301
  <button
302
302
  type="button"
303
303
  className={css.pill}
@@ -306,7 +306,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
306
306
  aria-label={officialTps === null ? counts : counts + ' · ' + officialTps}
307
307
  onClick={() => { setOpen(!open) }}
308
308
  >
309
- <IconGaugeOutline16 />
309
+ <GaugeIcon />
310
310
  {label}
311
311
  </button>
312
312
  {open && createPortal(
@@ -320,7 +320,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
320
320
  >
321
321
  <div className={css.dialogTitle}>
322
322
  <span className={css.dialogTitleLabel}>
323
- <IconGaugeOutline16 />
323
+ <GaugeIcon />
324
324
  {t('stats.dialog.title')}
325
325
  </span>
326
326
  </div>
@@ -372,18 +372,20 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
372
372
  }) {
373
373
  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
374
374
  const display = sessionUsageForDisplay(usage, tree?.tree ?? null)
375
- const subagentTotals = display.subagents
376
- const showSubagents = display.subagentSessions > 0
377
375
  const billed = display.total
378
376
  // Same aggregate as the official pill total: every prompt-side billing bucket plus output.
379
377
  const total = billedTotal(billed)
380
378
  const totalText = t('message.turnUsage.count', { count: formatTokensCompact(total) })
381
- const cacheHit = cacheHitPercentPrecise(billed)
379
+ // Native formatting, one consistent scope: the ratio comes from the same
380
+ // aggregate the headline uses, so "33M tok · 87%" can never contradict the
381
+ // number printed beside it. The integer shape and its >99% guard are the
382
+ // official ones, untouched.
383
+ const cacheHit = cacheHitPercent(billed)
382
384
  const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
383
385
  const exactCount = (value: number): string =>
384
386
  t('message.turnUsage.count', { count: formatTokens(value) })
385
387
  return (
386
- <span ref={rootRef} className={anchorClass(dock)}>
388
+ <span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
387
389
  <button
388
390
  type="button"
389
391
  className={css.pill}
@@ -392,19 +394,13 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
392
394
  aria-label={cacheHitText === null ? totalText : totalText + ' · ' + cacheHitText}
393
395
  onClick={() => { setOpen(!open) }}
394
396
  >
395
- <IconDatabaseOutline16 />
397
+ <DatabaseIcon />
396
398
  <span className={css.label}>
397
399
  {totalText}
398
- {showSubagents && (
399
- <span className={css.sep} aria-hidden>·</span>
400
- )}
401
- {showSubagents && (
402
- <span className={css.subagentMark} title={t('stats.dialog.subagents')}>
403
- +{tree?.tree?.subagentSessions ?? 0}
404
- </span>
405
- )}
406
400
  {cacheHitText !== null && (
407
401
  <>
402
+ {/* Separator only when the second figure exists: an empty span
403
+ would still take the label's gap and read as a trailing dot. */}
408
404
  <span className={css.sep} aria-hidden>·</span>
409
405
  {cacheHitText}
410
406
  </>
@@ -422,12 +418,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
422
418
  >
423
419
  <div className={css.dialogTitle}>
424
420
  <span className={css.dialogTitleLabel}>
425
- <IconDatabaseOutline16 />
421
+ <DatabaseIcon />
426
422
  {t('stats.dialog.usageTitle')}
427
423
  </span>
428
424
  <span className={css.dialogTitleValue}>{exactCount(total)}</span>
429
425
  </div>
430
426
  <div className={css.dialogTitleRule} aria-hidden />
427
+ {/* Exactly the official dialog's rows, in the official order: the
428
+ takeover adds the tree to the TOTAL, not to the breakdown. */}
431
429
  <dl className={css.dialogDetails} data-session-stats-usage>
432
430
  {cacheHit !== null && (
433
431
  <>
@@ -439,28 +437,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
439
437
  <dd>{exactCount(billed.uncachedInputTokens)}</dd>
440
438
  <dt>{t('message.turnUsage.cacheRead')}</dt>
441
439
  <dd>{exactCount(billed.cacheReadTokens)}</dd>
442
- <dt>{t('message.turnUsage.cacheWrite')}</dt>
443
- <dd>{exactCount(billed.cacheWriteTokens)}</dd>
444
- <dt>{t('stats.cacheMiss')}</dt>
445
- <dd>{exactCount(tokenBreakdown(billed).cacheMiss)}</dd>
446
- <dt>{t('message.turnUsage.output')}</dt>
447
- <dd>{exactCount(billed.outputTokens)}</dd>
448
- {/* The split that the native figure cannot show: this session's own
449
- main agent against every subagent it delegated to. */}
450
- {showSubagents && (
440
+ {billed.cacheWriteTokens !== 0 && (
451
441
  <>
452
- <dt>{t('stats.dialog.main')}</dt>
453
- <dd>{exactCount(billedTotal(display.self))}</dd>
454
- <dt>{t('stats.dialog.subagents')}</dt>
455
- <dd>
456
- {exactCount(billedTotal(subagentTotals))}
457
- {' · '}
458
- {t('stats.dialog.subagentCount', { count: tree?.tree?.subagentSessions ?? 0 })}
459
- </dd>
460
- <dt>{t('stats.dialog.treeTotal')}</dt>
461
- <dd>{exactCount(billedTotal(billed))}</dd>
442
+ <dt>{t('message.turnUsage.cacheWrite')}</dt>
443
+ <dd>{exactCount(billed.cacheWriteTokens)}</dd>
462
444
  </>
463
445
  )}
446
+ <dt>{t('message.turnUsage.output')}</dt>
447
+ <dd>{exactCount(billed.outputTokens)}</dd>
464
448
  </dl>
465
449
  </div>,
466
450
  document.body,
@@ -282,7 +282,7 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
282
282
  * (zero input/output but real cache hits) or a failed-call day (requests
283
283
  * but no tokens) both carry real usage and must not read as "no data". */
284
284
  function isEmptyRange(stats: TokenTotalRange): boolean {
285
- return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.turns === 0
285
+ return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.failures === 0 && stats.turns === 0
286
286
  }
287
287
 
288
288
  function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
@@ -299,8 +299,10 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
299
299
  { icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}` },
300
300
  // 缓存命中率 —cached share of all prompt tokens in the range.
301
301
  { icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}` },
302
- // 请求数量 —provider calls (tokens or not).
303
- { icon: MessagesSquare, label: t('requests'), value: String(stats.requests) },
302
+ // 请求数量 —provider calls (tokens or not). The failed share sits
303
+ // underneath, and only when there were any: a healthy range stays a
304
+ // single line. Failed calls are a SUBSET of requests, never additive.
305
+ { icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined },
304
306
  ]
305
307
  return (
306
308
  <div className={css.cards}>
@@ -0,0 +1,54 @@
1
+ export interface ContextPressureLike {
2
+ projectedTokens?: number
3
+ pressureTokens?: number
4
+ contextWindow?: number
5
+ }
6
+
7
+ export interface ContextBreakdownLike {
8
+ systemTokens: number
9
+ toolsTokens: number
10
+ messageTokens: number
11
+ }
12
+
13
+ export interface ContextOccupancy {
14
+ percent: number
15
+ usedTokens: number
16
+ contextWindow: number
17
+ }
18
+
19
+ /** Mirrors the host meter's occupancy calculation exactly. */
20
+ export function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null {
21
+ const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
22
+ if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
23
+ return {
24
+ percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
25
+ usedTokens,
26
+ contextWindow: pressure.contextWindow,
27
+ }
28
+ }
29
+
30
+ export function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number {
31
+ return breakdown === undefined ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
32
+ }
33
+
34
+ export function contextBreakdownSegments(
35
+ percent: number,
36
+ breakdown: ContextBreakdownLike | undefined,
37
+ ): Array<{ key: string; width: number }> {
38
+ const total = contextBreakdownTotal(breakdown)
39
+ if (breakdown === undefined || total === 0) return [{ key: 'total', width: percent }]
40
+ return [
41
+ { key: 'systemTokens', width: percent * breakdown.systemTokens / total },
42
+ { key: 'toolsTokens', width: percent * breakdown.toolsTokens / total },
43
+ { key: 'messageTokens', width: percent * breakdown.messageTokens / total },
44
+ ].filter(segment => segment.width > 0)
45
+ }
46
+
47
+ export function formatContextTokens(value: number, units: { thousand: string; million: string }): string {
48
+ const scaled = (candidate: number): string => candidate >= 100
49
+ ? String(Math.round(candidate))
50
+ : String(Math.round(candidate * 10) / 10)
51
+ if (value < 1e3) return String(value)
52
+ if (value < 1e6) return units.thousand.replace('{value}', scaled(value / 1e3))
53
+ return units.million.replace('{value}', scaled(value / 1e6))
54
+ }
@@ -0,0 +1,35 @@
1
+ /** The host ContextMeter is hard-coded beside composer.dock, not a slot.
2
+ * Suppress only that recognizable sibling while our replacement is mounted;
3
+ * never hide a whole dock, slot wrapper, or another plugin's readout. */
4
+ export function installContextTakeover(anchor: HTMLElement): () => void {
5
+ const dock = anchor.closest<HTMLElement>('[data-composer-dock]')
6
+ const view = anchor.ownerDocument.defaultView
7
+ if (dock === null || view === null) return () => {}
8
+ const hidden = new Map<HTMLElement, { display: string; priority: string }>()
9
+ const reconcile = (): void => {
10
+ for (const sibling of Array.from(dock.children)) {
11
+ if (!(sibling instanceof view.HTMLElement) || sibling.tagName !== 'SPAN'
12
+ || sibling.contains(anchor) || sibling.hasAttribute('data-slot-entry')
13
+ || sibling.hasAttribute('data-dsh-token-total-stat')
14
+ || hidden.has(sibling)) continue
15
+ // ContextMeter's stable SVG geometry, not a private CSS-module hash or
16
+ // a translated label. Current hosts mount its span directly in the dock.
17
+ if (sibling.querySelector('button[aria-haspopup="dialog"] svg[viewBox="0 0 14 14"] circle[cx="7"][cy="7"][r="5.5"]') === null) continue
18
+ hidden.set(sibling, { display: sibling.style.getPropertyValue('display'), priority: sibling.style.getPropertyPriority('display') })
19
+ sibling.setAttribute('data-dsh-token-total-native-context', '')
20
+ sibling.style.setProperty('display', 'none', 'important')
21
+ }
22
+ }
23
+ reconcile()
24
+ const observer = new view.MutationObserver(reconcile)
25
+ observer.observe(dock, { childList: true, subtree: true })
26
+ return () => {
27
+ observer.disconnect()
28
+ for (const [element, previous] of hidden) {
29
+ element.removeAttribute('data-dsh-token-total-native-context')
30
+ if (previous.display === '') element.style.removeProperty('display')
31
+ else element.style.setProperty('display', previous.display, previous.priority)
32
+ }
33
+ hidden.clear()
34
+ }
35
+ }