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.
- package/README.md +2 -1
- package/lib/client.js +482 -98
- package/lib/index.js +196 -11
- package/lib/types/client/ContextUsageEntry.d.ts +22 -0
- package/lib/types/client/context-meter-core.d.ts +26 -0
- package/lib/types/client/context-takeover.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-core.d.ts +1 -2
- package/lib/types/client/stats-icons.d.ts +4 -0
- package/lib/types/client/use-session-usage.d.ts +1 -1
- package/lib/types/collector.d.ts +19 -3
- package/lib/types/query.d.ts +8 -0
- package/lib/types/store.d.ts +32 -0
- package/lib/types/wire.d.ts +7 -0
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +130 -0
- package/src/client/ContextUsageEntry.tsx +93 -0
- package/src/client/StatsLineEnhanced.module.css +15 -24
- package/src/client/StatsLineEnhanced.tsx +35 -51
- package/src/client/TokenTotalPanel.tsx +5 -3
- package/src/client/context-meter-core.ts +54 -0
- package/src/client/context-takeover.ts +35 -0
- package/src/client/index.tsx +21 -3
- package/src/client/locales.ts +4 -0
- package/src/client/session-usage-core.ts +7 -11
- package/src/client/stats-icons.tsx +83 -0
- package/src/client/use-session-usage.ts +1 -1
- package/src/collector.ts +81 -16
- package/src/index.ts +29 -0
- package/src/query.ts +23 -8
- package/src/routes.ts +6 -0
- package/src/store.ts +136 -0
- package/src/wire.ts +7 -0
|
@@ -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:
|
|
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:
|
|
69
|
-
background:
|
|
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
|
-
/*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
the
|
|
110
|
-
|
|
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
|
|
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:
|
|
129
|
+
padding: 12px;
|
|
139
130
|
border: 0;
|
|
140
|
-
border-radius:
|
|
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:
|
|
146
|
-
line-height:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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,
|
|
55
|
+
billedInputTokens, cacheHitPercent, deriveStats,
|
|
53
56
|
estimateOutputTokens, formatDuration, formatTokensCompact, formatTokensPerSecond,
|
|
54
|
-
measuredTokenScale, StreamingRateSampler,
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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('
|
|
453
|
-
<dd>{exactCount(
|
|
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
|
-
|
|
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
|
+
}
|