dsh-token-total 1.0.7 → 1.0.9

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,222 @@
1
+ import { useLayoutEffect, useMemo, useRef, useState } from 'react'
2
+ import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts'
3
+ import { CONTEXT_CATEGORY_ORDER, contextCategoryTotals, contextReading, treemapLayout } from './context-window-core.ts'
4
+ import { cacheRate, formatCompact, formatPercent, formatTokens } from './format.ts'
5
+ import type { SessionUsageKey } from './session-usage-locales.ts'
6
+ import css from './ContextWindowPanel.module.css'
7
+
8
+ type T = (key: SessionUsageKey, params?: Record<string, string>) => string
9
+
10
+ const CATEGORY_LABEL: Record<SessionContextCategory, SessionUsageKey> = {
11
+ system: 'contextSystem', tools: 'contextTools', memory: 'contextMemory',
12
+ files: 'contextFiles', results: 'contextResults', conversation: 'contextConversation',
13
+ }
14
+ const CATEGORY_TONE: Record<SessionContextCategory, string> = {
15
+ system: css.toneSystem, tools: css.toneTools, memory: css.toneMemory,
16
+ files: css.toneFiles, results: css.toneResults, conversation: css.toneConversation,
17
+ }
18
+ /** Which locale string names an injected/system row, keyed by its log kind. */
19
+ const KIND_LABEL: Record<string, SessionUsageKey> = {
20
+ 'system-prompt': 'contextKindSystemPrompt',
21
+ 'runtime-context': 'contextKindRuntimeContext',
22
+ 'skill-catalog': 'contextKindSkillCatalog',
23
+ 'time-context': 'contextKindTimeContext',
24
+ 'compaction-summary': 'contextKindCompaction',
25
+ user: 'contextKindUser',
26
+ assistant: 'contextKindAssistant',
27
+ }
28
+
29
+ /** What one treemap tile is called: a tool name or path as-is, an
30
+ * injected kind through the dictionary, and a folded tail as "N more". */
31
+ function itemText(item: SessionContextItem, t: T): string {
32
+ if (item.kind === 'rest') return t('contextKindRest', { count: String(item.count) })
33
+ const label = item.kind === undefined ? undefined : KIND_LABEL[item.kind]
34
+ const base = label !== undefined ? t(label) : item.label || item.kind || ''
35
+ // Repeated entries of one kind (a tool ran 63 times, five user prompts) keep
36
+ // their multiplicity visible: that repetition is what makes them bulky.
37
+ return item.count > 1 ? `${base} ${t('contextTimes', { count: String(item.count) })}` : base
38
+ }
39
+
40
+ const sumOf = (items: readonly SessionContextItem[]): number => items.reduce((sum, item) => sum + item.tokens, 0)
41
+
42
+ /** Wall-clock stamp of the last successful read, same shape as the page's. */
43
+ const clock = (ms: number): string => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
44
+
45
+ /** Hover-card footprint estimate, so the tip can be kept inside the map
46
+ * without measuring itself first. */
47
+ const TIP_W = 220
48
+ const TIP_H = 70
49
+
50
+ /** The hover card for one tile: name and call count, the exact token count
51
+ * with its category, and the block's share of the estimate and of the
52
+ * model's window — the figures a tile has no room to print. */
53
+ function Tip({ item, box, map, t, total, contextWindow }: {
54
+ item: SessionContextItem
55
+ box: { x: number; y: number; width: number; height: number }
56
+ map: { width: number; height: number }
57
+ t: T
58
+ total: number
59
+ contextWindow: number | null
60
+ }): JSX.Element {
61
+ const share = total > 0 ? formatPercent(item.tokens / total * 100) : null
62
+ const windowShare = contextWindow !== null && contextWindow > 0 ? formatPercent(item.tokens / contextWindow * 100) : null
63
+ // Under the tile's center by default, flipping above it near the map floor,
64
+ // clamped to the map so overflow:hidden never clips the tip away.
65
+ const left = Math.min(Math.max(box.x + box.width / 2 - TIP_W / 2, 4), Math.max(4, map.width - TIP_W - 4))
66
+ const top = box.y + box.height + 6 + TIP_H < map.height - 4
67
+ ? box.y + box.height + 6
68
+ : Math.max(4, box.y - TIP_H - 6)
69
+ return <div className={css.tip} role="tooltip" style={{ left, top }}>
70
+ <div className={css.tipName}>{itemText(item, t)}</div>
71
+ <div className={css.tipMeta}>{formatTokens(item.tokens)} token · {t(CATEGORY_LABEL[item.category])}</div>
72
+ {share !== null && <div className={css.tipShare}>
73
+ {t('contextTipShare', { percent: share })}{windowShare === null ? '' : ` · ${t('contextTipWindow', { percent: windowShare })}`}
74
+ </div>}
75
+ </div>
76
+ }
77
+
78
+ function Treemap({ items, t, contextWindow }: { items: SessionContextItem[]; t: T; contextWindow: number | null }): JSX.Element {
79
+ const hostRef = useRef<HTMLDivElement | null>(null)
80
+ const [size, setSize] = useState({ width: 0, height: 0 })
81
+ // Hover state is keyed by block, not by tile instance, so the tip follows its
82
+ // block across refreshes even while the boxes re-lay-out underneath it.
83
+ const [hoverKey, setHoverKey] = useState<string | null>(null)
84
+ // The treemap needs real pixels: measure the box and re-lay out on resize
85
+ // (the Usage page is a container-query surface, so widths change freely).
86
+ useLayoutEffect(() => {
87
+ const host = hostRef.current
88
+ if (host === null) return
89
+ const measure = (): void => setSize(previous => {
90
+ const width = host.clientWidth
91
+ const height = host.clientHeight
92
+ return previous.width === width && previous.height === height ? previous : { width, height }
93
+ })
94
+ measure()
95
+ if (typeof ResizeObserver === 'undefined') {
96
+ window.addEventListener('resize', measure)
97
+ return () => window.removeEventListener('resize', measure)
98
+ }
99
+ const observer = new ResizeObserver(measure)
100
+ observer.observe(host)
101
+ return () => observer.disconnect()
102
+ }, [])
103
+ const boxes = useMemo(
104
+ () => treemapLayout(items.map(item => ({ key: item.key, tokens: item.tokens })), size.width, size.height),
105
+ [items, size.width, size.height],
106
+ )
107
+ const byKey = useMemo(() => new Map(items.map(item => [item.key, item])), [items])
108
+ const hoverBox = hoverKey === null ? undefined : boxes.find(box => box.key === hoverKey)
109
+ const hoverItem = hoverBox === undefined ? undefined : byKey.get(hoverBox.key)
110
+ return <div ref={hostRef} className={css.map} onMouseLeave={() => setHoverKey(null)}>
111
+ {boxes.map(box => {
112
+ const item = byKey.get(box.key)
113
+ if (item === undefined || box.width <= 0 || box.height <= 0) return null
114
+ const text = itemText(item, t)
115
+ // Tiles shed their captions before they shed their color: a label needs
116
+ // room for two glyphs plus padding, the size line needs a second row.
117
+ const showLabel = box.width >= 46 && box.height >= 22
118
+ const showSize = box.width >= 62 && box.height >= 40
119
+ return <div key={box.key} className={css.slot}
120
+ style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
121
+ onMouseEnter={() => setHoverKey(box.key)}
122
+ aria-label={`${text} · ${formatTokens(item.tokens)} token · ${t(CATEGORY_LABEL[item.category])}`}>
123
+ <div className={`${css.tile} ${CATEGORY_TONE[item.category]}`}>
124
+ {showLabel && <span className={css.tileLabel}>{text}</span>}
125
+ {showSize && <span className={css.tileSize}>{formatCompact(item.tokens)} · {t(CATEGORY_LABEL[item.category])}</span>}
126
+ </div>
127
+ </div>
128
+ })}
129
+ {hoverBox !== undefined && hoverItem !== undefined &&
130
+ <Tip item={hoverItem} box={hoverBox} map={size} t={t} total={sumOf(items)} contextWindow={contextWindow} />}
131
+ </div>
132
+ }
133
+
134
+ /** The context-window panel above the Usage cards, in magpie's shape: the used
135
+ * total and the model's window on the left, the newest answer's prompt-cache
136
+ * split on the right, a treemap of what occupies the window below (with a
137
+ * hover card per tile), and one legend row naming the six buckets with their
138
+ * sizes. Every figure is an estimate, so the panel never claims an exact
139
+ * count — and the live pill stamps when the estimate was last refreshed. */
140
+ export function ContextWindowPanel({ context, t, updatedAt }: {
141
+ context: SessionContextWindow
142
+ t: T
143
+ /** Wall-clock ms of the last successful read, when the host reports one. */
144
+ updatedAt?: number | null
145
+ }): JSX.Element {
146
+ const [open, setOpen] = useState(true)
147
+ const totals = contextCategoryTotals(context.items)
148
+ const reading = contextReading(context)
149
+ const percent = reading.percent
150
+ const hitRate = context.cacheKnown ? cacheRate(context.cacheHitTokens, context.cacheMissTokens) : null
151
+ const model = context.model !== '' && !context.model.includes('/') && context.provider !== ''
152
+ ? `${context.provider}/${context.model}`
153
+ : context.model
154
+ const windowText = context.contextWindow === null
155
+ ? t('contextWindowUnknown')
156
+ : t('contextWindowKnown', {
157
+ value: formatCompact(context.contextWindow),
158
+ percent: String(percent ?? 0),
159
+ })
160
+ return <section className={css.panel} data-dsh-context-window>
161
+ {/* Collapsed, the body's spacing is gone, so the header pads its own
162
+ bottom — otherwise the folded row reads heavy on top. */}
163
+ <header className={open ? css.head : `${css.head} ${css.headCollapsed}`}>
164
+ <button type="button" className={css.toggle} onClick={() => setOpen(!open)} aria-expanded={open} aria-controls="dsh-context-window-body">
165
+ <svg className={`${css.chevron} ${open ? css.chevronOpen : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
166
+ <h2>{t('contextTitle')}</h2>
167
+ </button>
168
+ <div className={css.headRight}>
169
+ {model !== '' && <span className={css.model} title={`${context.provider} / ${context.model}`}>{model}</span>}
170
+ <span className={css.livePill}>
171
+ <span className={css.liveDot} aria-hidden="true" />
172
+ {t('contextLive')}
173
+ {typeof updatedAt === 'number' && <span className={css.liveTime}> · {clock(updatedAt)}</span>}
174
+ </span>
175
+ </div>
176
+ </header>
177
+ <div id="dsh-context-window-body" className={css.body} hidden={!open}>
178
+ <div className={css.summary}>
179
+ <div className={css.used}>
180
+ <span className={css.label}>{t('contextUsed')}</span>
181
+ <strong className={css.total} title={formatTokens(reading.usedTokens)}>{formatCompact(reading.usedTokens)}<small>token</small></strong>
182
+ <span className={css.window}>{windowText}</span>
183
+ </div>
184
+ <div className={css.cache}>
185
+ <span className={css.label}>{t('contextCache')}</span>
186
+ <div className={css.cacheBar}>
187
+ {hitRate === null
188
+ ? <span className={css.cacheEmpty} />
189
+ : <>
190
+ <span className={css.cacheHit} style={{ width: `${hitRate}%` }} />
191
+ <span className={css.cacheMiss} style={{ width: `${100 - hitRate}%` }} />
192
+ </>}
193
+ </div>
194
+ <div className={css.cacheLegend}>
195
+ {hitRate === null
196
+ ? <span className={css.cachePending}>{t('contextCachePending')}</span>
197
+ : <>
198
+ <span><i className={css.cacheHitDot} aria-hidden="true" />{t('contextCacheHit')} {formatPercent(hitRate)}</span>
199
+ <span><i className={css.cacheMissDot} aria-hidden="true" />{t('contextCacheMiss')} {formatPercent(100 - hitRate)}</span>
200
+ </>}
201
+ </div>
202
+ </div>
203
+ </div>
204
+ {reading.estimateDiffers && <p className={css.note}>
205
+ {t('contextEstimateNote', { estimate: formatCompact(reading.estimateTokens), billed: formatCompact(reading.usedTokens) })}
206
+ </p>}
207
+ {context.items.length === 0
208
+ ? <p className={css.empty}>{t('contextEmpty')}</p>
209
+ : <Treemap items={context.items} t={t} contextWindow={context.contextWindow} />}
210
+ {totals.length > 0 && <ul className={css.legend}>
211
+ {totals.map(row => <li key={row.key}>
212
+ <span className={`${css.swatch} ${CATEGORY_TONE[row.key]}`} aria-hidden="true" />
213
+ <span className={css.legendName}>{t(CATEGORY_LABEL[row.key])}</span>
214
+ <span className={css.legendValue}>{formatCompact(row.tokens)}</span>
215
+ </li>)}
216
+ </ul>}
217
+ </div>
218
+ </section>
219
+ }
220
+
221
+ /** Legend order shared with the CSS tone classes (kept exported for tests). */
222
+ export const CONTEXT_LEGEND_ORDER = CONTEXT_CATEGORY_ORDER
@@ -1,46 +1,57 @@
1
- /* A light, self-contained Usage surface. Tables fit the available width:
2
- * no horizontal scroll containers, fixed column contracts, titles for names.
3
- * Stat cards follow the reference dashboard: tinted left edge + icon chip,
4
- * label, big value, small subtitle. Request log folds by conversation turn. */
5
- .scroll { height:100%; min-height:0; overflow-y:auto; background:#ffffff; color:#252936; color-scheme:light; container-type:inline-size; }
6
- .page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:system-ui,-apple-system,"Segoe UI",sans-serif; font-size:13px; }
1
+ /* DSH-native Usage surface: every colour rides the host semantic tokens
2
+ * (--dsw-alias-*), so the page follows the DSH light/dark theme — the light
3
+ * canvas is the host's pure-white bg-base, the dark canvas its near-black
4
+ * base, and surfaces separate by border (the host's flat-light convention)
5
+ * instead of by canvas tint. Tables fit the available width: no horizontal
6
+ * scroll containers, fixed column contracts, titles for names. Six stat cards
7
+ * sit in one row (1050px and below: 3-up, 700px and below: 2-up) as quiet
8
+ * bordered surfaces: no accent edges or watermarks, a flat tone chip leading
9
+ * each label, hierarchy from the type scale. Request log folds by
10
+ * conversation turn.
11
+ * Accent hues are this plugin's tone scale — the same accents the dashboard
12
+ * cards wear. Dark values flip under body[data-ds-dark-theme], the host's own
13
+ * dark mechanism (never a prefers-color-scheme media query: the host theme is
14
+ * app-level and can disagree with the OS). */
15
+ .scroll { height:100%; min-height:0; overflow-y:auto; background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); container-type:inline-size; }
16
+ .page { box-sizing:border-box; width:100%; max-width:1160px; margin:0 auto; padding:24px 26px 32px; font-family:var(--dsw-font-family); font-size:13px; }
7
17
  .page * { box-sizing:border-box; }
8
18
  /* Freshness header: live badge + last-read stamp on the left, manual refresh
9
19
  * pinned to the right edge. */
10
20
  .head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 12px; }
11
- .headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:#8790a3; }
12
- .liveDot { width:6px; height:6px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 3px #e5f6f0; flex-shrink:0; }
13
- .asOf { color:#9aa3b3; font-variant-numeric:tabular-nums; white-space:nowrap; }
14
- .refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color:#6f7a8d; background:#fff; border:1px solid #e4e7ee; border-radius:8px; padding:5px 11px; font:inherit; font-size:11px; cursor:pointer; }
15
- .refresh:hover { color:#279cb6; border-color:#cbd3e0; }
21
+ .headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:var(--dsw-alias-label-tertiary); }
22
+ .liveDot { width:6px; height:6px; border-radius:50%; background:var(--dsw-alias-state-success-primary); box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary) 15%, transparent); flex-shrink:0; }
23
+ .asOf { color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; white-space:nowrap; }
24
+ .refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l2); border-radius:8px; padding:5px 11px; font:inherit; font-size:11px; cursor:pointer; }
25
+ .refresh:hover { color:var(--dsw-alias-label-primary); border-color:var(--dsw-alias-border-l3); background:var(--dsw-alias-interactive-bg-hover); }
16
26
  .refresh:disabled { opacity:.55; cursor:default; }
17
- .scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
18
- .stats { display:grid; gap:12px; margin-bottom:18px; }
19
- .statRow { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
20
- .stat { min-width:0; position:relative; overflow:hidden; background:#fff; border:1px solid #e4e7ee; border-left:4px solid var(--tone); border-radius:12px; padding:12px 16px 11px; }
21
- .statHead { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
22
- .statIcon { display:flex; align-items:center; justify-content:center; width:32px; height:32px; border-radius:10px; color:#fff; background:linear-gradient(135deg, var(--tone), var(--tone-soft)); flex-shrink:0; box-shadow:0 3px 8px var(--tone-soft); }
23
- .statMark { position:absolute; right:-14px; bottom:-18px; width:92px; height:92px; color:var(--tone); pointer-events:none; }
24
- .statLabel { font-size:12px; color:#7d8495; }
25
- .statValue { display:block; font-size:25px; font-weight:650; letter-spacing:-0.5px; white-space:nowrap; font-variant-numeric:tabular-nums; }
26
- .statSub { display:block; margin-top:5px; font-size:11px; color:#939aac; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
27
- .toneBlue { --tone:#4f79f6; --tone-soft:#eaf0fe; }
28
- .tonePurple { --tone:#8b5cf6; --tone-soft:#f1ebfe; }
29
- .toneGreen { --tone:#18a97b; --tone-soft:#e5f6f0; }
30
- .toneOrange { --tone:#e8892c; --tone-soft:#fcf1e4; }
31
- .toneTeal { --tone:#16a4b8; --tone-soft:#e4f5f8; }
32
- .tonePink { --tone:#d9538c; --tone-soft:#fbeaf1; }
33
- .card { min-width:0; background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin-bottom:18px; overflow:hidden; }
34
- .cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid #eef0f4; }
35
- .cardHead h2 { font-size:14px; font-weight:600; margin:0; }
27
+ .scope { color:var(--dsw-alias-label-tertiary); font-size:11px; margin:0 0 14px; }
28
+ .stats { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:8px; margin-bottom:16px; }
29
+ .stat { min-width:0; position:relative; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l1); border-radius:12px; padding:12px 14px 11px; box-shadow:0 1px 2px var(--dsw-alias-bg-mask-2); transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
30
+ .stat:hover { border-color:var(--dsw-alias-border-l2); box-shadow:0 14px 30px -22px var(--dsw-alias-bg-mask-1), 0 1px 2px var(--dsw-alias-bg-mask-2); transform:translateY(-1px); }
31
+ .statHead { display:flex; align-items:center; gap:5px; margin-bottom:6px; }
32
+ .statIcon { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:6px; color:var(--tone); background:color-mix(in srgb, var(--tone) 14%, transparent); flex-shrink:0; }
33
+ .statLabel { font-size:12px; font-weight:500; color:var(--dsw-alias-label-tertiary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
34
+ .statValue { display:block; font-size:21px; font-weight:650; letter-spacing:-0.4px; color:var(--dsw-alias-label-primary); white-space:nowrap; font-variant-numeric:tabular-nums; }
35
+ .statSub { display:block; margin-top:8px; padding-top:7px; border-top:1px solid var(--dsw-alias-border-l1); font-size:11px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
36
+ /* Accent hues: this plugin's tone scale, matching the dashboard cards' chart
37
+ * accents. The soft chip is a translucent mix of the hue itself, so it flips
38
+ * with the theme automatically; only the base hue needs a dark value (below). */
39
+ .toneBlue { --tone:#4d6bfe; }
40
+ .tonePurple { --tone:#975bf1; }
41
+ .toneGreen { --tone:#00b96b; }
42
+ .toneOrange { --tone:#e07b39; }
43
+ .toneTeal { --tone:#0d8a8a; }
44
+ .card { min-width:0; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l2); border-radius:12px; margin-bottom:18px; overflow:hidden; }
45
+ .cardHead { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:54px; padding:15px 18px; border-bottom:1px solid var(--dsw-alias-border-l1); }
46
+ .cardHead h2 { font-size:14px; font-weight:600; margin:0; color:var(--dsw-alias-label-primary); }
36
47
  .cardHead > span { display:flex; gap:7px; align-items:center; font-size:11px; }
37
- .count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:#8b93a3; background:#f3f5f8; border-radius:5px; margin-left:5px; font-weight:500; }
48
+ .count { display:inline-flex; align-items:center; justify-content:center; min-width:21px; padding:2px 6px; font-size:10px; color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-markdown-tag); border-radius:5px; margin-left:5px; font-weight:500; }
38
49
  .mid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; align-items:stretch; }
39
50
  .mid .card { height:100%; margin-bottom:0; }
40
51
  .mid { margin-bottom:18px; }
41
52
  .table { border-collapse:collapse; width:100%; table-layout:fixed; font-size:11px; }
42
- .table th { color:#929aaa; font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }
43
- .table td { padding:13px 5px; text-align:right; border-top:1px solid #eef0f4; color:#626c80; font-variant-numeric:tabular-nums; white-space:nowrap; }
53
+ .table th { color:var(--dsw-alias-label-tertiary); font-weight:500; padding:12px 5px; text-align:right; vertical-align:middle; line-height:1.3; overflow-wrap:anywhere; }
54
+ .table td { padding:13px 5px; text-align:right; border-top:1px solid var(--dsw-alias-border-l1); color:var(--dsw-alias-label-secondary); font-variant-numeric:tabular-nums; white-space:nowrap; }
44
55
  .table th:first-child,.table td:first-child { text-align:left; padding-left:16px; }
45
56
  .table th:last-child,.table td:last-child { padding-right:16px; }
46
57
  .childName { width:28%; }
@@ -48,69 +59,71 @@
48
59
  .modelName { width:30%; }
49
60
  .modelRequests { width:11%; }
50
61
  .modelHit { width:16%; }
51
- .ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#394354; }
62
+ .ellipsis { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--dsw-alias-label-primary-dimmed); }
52
63
  /* By-model rows lead with the model's brand logo; the name keeps truncating
53
64
  * inside the flexible remainder of the cell. */
54
65
  .modelNameCell { display:flex; align-items:center; gap:6px; min-width:0; }
55
66
  .modelNameCell .ellipsis { flex:1; min-width:0; }
56
67
  /* Gray provider label under a model name shown by several rows (同名模型). */
57
- .modelProvider { display:block; margin-top:2px; font-size:10px; color:#939aaa; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
68
+ .modelProvider { display:block; margin-top:2px; font-size:10px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
58
69
  .number { font-variant-numeric:tabular-nums; }
59
70
  .speedCell { display:inline-flex; align-items:baseline; gap:3px; white-space:nowrap; }
60
- .speedCell small { font-size:9px; color:#929aaa; }
61
- .good { color:#21a17b; }
62
- .warn { color:#d69a21; }
63
- .bad { color:#e06571; }
64
- .live { color:#169bb3; }
65
- .muted { color:#939aaa; }
66
- .empty { margin:0; padding:28px 18px; color:#9aa2b1; font-size:12px; text-align:center; }
67
- .action { color:#818b9e; background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
68
- .action:hover { color:#279cb6; }
69
- .action:focus-visible,.turnHead:focus-visible,.refresh:focus-visible { outline:2px solid #2ab1c6; outline-offset:-2px; }
71
+ .speedCell small { font-size:9px; color:var(--dsw-alias-label-tertiary); }
72
+ /* Status accents ride the host state tokens; the running/live teal is this
73
+ * plugin's tone-5 accent (the host has no teal state token). */
74
+ .good { color:var(--dsw-alias-state-success-primary); }
75
+ .warn { color:var(--dsw-alias-state-warn-label); }
76
+ .bad { color:var(--dsw-alias-state-error-primary); }
77
+ .live { color:#0d8a8a; }
78
+ .muted { color:var(--dsw-alias-label-tertiary); }
79
+ .empty { margin:0; padding:28px 18px; color:var(--dsw-alias-label-tertiary); font-size:12px; text-align:center; }
80
+ .action { color:var(--dsw-alias-label-secondary); background:none; border:0; padding:3px 0; font:inherit; font-size:11px; cursor:pointer; }
81
+ .action:hover { color:var(--dsw-alias-label-link); }
82
+ .action:focus-visible,.turnHead:focus-visible,.refresh:focus-visible { outline:2px solid var(--dsw-alias-label-link); outline-offset:-2px; }
70
83
  .turnList { padding:0 14px 14px; }
71
- .turn { margin-top:10px; border:1px solid #e7eaf0; border-radius:9px; background:#fff; overflow:hidden; }
72
- .turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:#f8f9fc; border:0; cursor:pointer; color:#616c7f; font:inherit; font-size:11px; }
73
- .chevron { color:#99a1af; transition:transform 0.2s; }
84
+ .turn { margin-top:10px; border:1px solid var(--dsw-alias-border-l1); border-radius:9px; background:var(--dsw-alias-bg-layer-1); overflow:hidden; }
85
+ .turnHead { display:grid; grid-template-columns:12px minmax(0,1fr) auto; align-items:center; gap:12px; padding:13px 14px; width:100%; text-align:left; background:var(--dsw-alias-menu-group-header-fill); border:0; cursor:pointer; color:var(--dsw-alias-label-secondary); font:inherit; font-size:11px; }
86
+ .chevron { color:var(--dsw-alias-label-tertiary); transition:transform 0.2s; }
74
87
  .rotated { transform:rotate(90deg); }
75
- .turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:#394354; }
88
+ .turnTitle { display:flex; align-items:center; gap:6px; min-width:0; font-size:12px; font-weight:600; color:var(--dsw-alias-label-primary-dimmed); }
76
89
  /* Turn topic is the group title now (the page header owns the session name),
77
90
  * so it carries the primary text color and truncates before the metrics. */
78
91
  .turnTopic { min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
79
92
  /* A subagent's turns look like this session's turns apart from the agent tag;
80
93
  * the tag keeps the full session name in its tooltip. */
81
- .turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:#e9edf4; color:#6f7889; font-size:10px; font-weight:500; }
94
+ .turnAgent { flex:none; padding:1px 6px; border-radius:5px; background:var(--dsw-alias-interactive-bg-active); color:var(--dsw-alias-label-secondary); font-size:10px; font-weight:500; }
82
95
  .turnSummary { display:flex; gap:5px; align-items:center; white-space:nowrap; }
83
96
  .turnSummary > span:last-child { margin-left:8px; }
84
- .turnBody { border-top:1px solid #eef0f4; }
97
+ .turnBody { border-top:1px solid var(--dsw-alias-border-l1); }
85
98
  .turnBody[hidden] { display:none; }
86
99
  /* A request row is one line: time, what answered, and its metrics pinned to the
87
100
  * row end. The metrics never wrap; they truncate with a tooltip before the head
88
101
  * gives up its model name. */
89
- .turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid #f3f5f9; }
102
+ .turnRequest { display:grid; grid-template-columns:64px minmax(0,1fr) auto; align-items:center; gap:12px; padding:10px 16px; border-top:1px solid var(--dsw-alias-border-l1); }
90
103
  .turnRequest:first-child { border-top:0; }
91
- .turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:#616c7f; min-width:0; }
92
- .requestTime { color:#9aa3b3; font-variant-numeric:tabular-nums; flex-shrink:0; }
104
+ .turnRequestHead { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--dsw-alias-label-secondary); min-width:0; }
105
+ .requestTime { color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; flex-shrink:0; }
93
106
  .statusDot { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; }
94
107
  /* Per-model brand logo: colors are baked into each logo's markup, only the
95
108
  * layout contract lives here. Unknown models get the neutral gray fallback. */
96
109
  .modelLogo { flex-shrink:0; }
97
- .recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#394354; font-weight:500; }
98
- .routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#8b93a4; flex-shrink:1; }
99
- .effort { white-space:nowrap; color:#9aa3b3; background:#f1f3f7; border-radius:5px; padding:1px 6px; }
100
- .recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:#949cab; font-variant-numeric:tabular-nums; }
101
- .note { font-size:10px; line-height:1.7; color:#9aa3b3; margin:0; }
102
- .error { background:#fff4f1; color:#b86751; border:1px solid #f4d8ce; border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
110
+ .recordModel { min-width:0; display:flex; align-items:center; gap:6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dsw-alias-label-primary-dimmed); font-weight:500; }
111
+ .routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--dsw-alias-label-tertiary); flex-shrink:1; }
112
+ .effort { white-space:nowrap; color:var(--dsw-alias-label-tertiary); background:var(--dsw-alias-markdown-tag); border-radius:5px; padding:1px 6px; }
113
+ .recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }
114
+ .note { font-size:10px; line-height:1.7; color:var(--dsw-alias-label-tertiary); margin:0; }
115
+ .error { background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color:var(--dsw-alias-state-error-primary); border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 35%, transparent); border-radius:9px; padding:12px 16px; margin-bottom:16px; font-size:12px; display:flex; gap:16px; justify-content:space-between; }
103
116
  @container (max-width:1050px) {
104
117
  .mid { grid-template-columns:minmax(0,1fr); }
105
- .statRow { grid-template-columns:repeat(2,minmax(0,1fr)); }
106
- .statValue { font-size:23px; }
118
+ .stats { grid-template-columns:repeat(3,minmax(0,1fr)); }
107
119
  .table { font-size:12px; }
108
120
  .childName { width:34%; }
109
121
  }
110
122
  @container (max-width:700px) {
111
123
  .page { padding:18px 14px 24px; }
112
- .stat { padding:12px 12px 11px; }
113
- .statValue { font-size:20px; }
124
+ .stats { grid-template-columns:repeat(2,minmax(0,1fr)); }
125
+ .stat { padding:10px 12px 9px; }
126
+ .statValue { font-size:18px; }
114
127
  .table { font-size:10px; }
115
128
  .table th,.table td { padding:10px 3px; }
116
129
  .table th:first-child,.table td:first-child { padding-left:10px; }
@@ -128,4 +141,16 @@
128
141
  }
129
142
  @media (prefers-reduced-motion:reduce) {
130
143
  .chevron { transition:none; }
144
+ .stat { transition:none; }
145
+ .stat:hover { transform:none; }
131
146
  }
147
+ /* Dark theme: the host sets body[data-ds-dark-theme] and every alias token
148
+ * above re-resolves on its own; only this plugin's own accent hues need
149
+ * explicit dark values — brighter variants of the dashboard's dark chart
150
+ * accents, so chips and running states hold their weight on dark cards. */
151
+ body[data-ds-dark-theme] .toneBlue { --tone:#7d95ff; }
152
+ body[data-ds-dark-theme] .tonePurple { --tone:#b18cff; }
153
+ body[data-ds-dark-theme] .toneGreen { --tone:#2fbf7c; }
154
+ body[data-ds-dark-theme] .toneOrange { --tone:#e89a5c; }
155
+ body[data-ds-dark-theme] .toneTeal { --tone:#3bc5c5; }
156
+ body[data-ds-dark-theme] .live { color:#3bc5c5; }
@@ -5,10 +5,11 @@ import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
5
5
  import { formatCompact, formatTokens, normalizeModelName, decodeRateOf } from './format.ts'
6
6
  import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
7
7
  import { useSessionDetail } from './use-session-detail.ts'
8
+ import { ContextWindowPanel } from './ContextWindowPanel.tsx'
8
9
  import { ModelLogo } from './ModelLogo.tsx'
9
10
  import css from './SessionUsageView.module.css'
10
11
 
11
- type T = (key: SessionUsageKey) => string
12
+ type T = (key: SessionUsageKey, params?: Record<string, string>) => string
12
13
  type Row = SessionUsageDetail['records'][number]
13
14
  export type SessionUsageViewProps = PropsLocale<typeof SESSION_USAGE_NS> & { sessionId: string }
14
15
 
@@ -45,19 +46,15 @@ const icons = {
45
46
  speed: 'M13 2 4 14h7l-1 8 9-12h-7l1-8Z',
46
47
  input: 'M12 3v12M7 10l5 5 5-5M4 21h16',
47
48
  output: 'M12 21V9M7 14l5-5 5 5M4 3h16',
48
- read: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7ZM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
49
- write: 'M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5Z',
50
49
  }
51
50
 
52
51
  function Stat({ tone, name, label, value, sub, title }: { tone: string; name: keyof typeof icons; label: string; value: string | JSX.Element; sub?: string; title?: string }): JSX.Element {
53
52
  const icon = icons[name]
54
53
  return <article className={`${css.stat} ${tone}`}>
55
- {/* Oversized gradient watermark filling the card's empty right side. */}
56
- <svg className={css.statMark} viewBox="0 0 24 24" aria-hidden="true">
57
- <defs><linearGradient id={`usage-mark-${name}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity="0.3" /><stop offset="1" stopColor="currentColor" stopOpacity="0.03" /></linearGradient></defs>
58
- <path d={icon} fill={`url(#usage-mark-${name})`} stroke={`url(#usage-mark-${name})`} strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
59
- </svg>
60
- <div className={css.statHead}><span className={css.statIcon}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span><span className={css.statLabel}>{label}</span></div>
54
+ <div className={css.statHead}>
55
+ <span className={css.statIcon}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={icon} /></svg></span>
56
+ <span className={css.statLabel}>{label}</span>
57
+ </div>
61
58
  <strong className={css.statValue} title={title}>{value}</strong>
62
59
  {sub !== undefined && <span className={css.statSub}>{sub}</span>}
63
60
  </article>
@@ -68,18 +65,12 @@ function Metrics({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
68
65
  const failed = detail.records.filter(row => row.status === 'failed').length
69
66
  const failRate = detail.requests > 0 ? (failed / detail.requests * 100).toFixed(1) : '0.0'
70
67
  return <div className={css.stats}>
71
- <div className={css.statRow}>
72
- <Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
73
- <Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
74
- <Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
75
- <Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
76
- </div>
77
- <div className={css.statRow}>
78
- <Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
79
- <Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
80
- <Stat tone={css.tonePurple} name="read" label={t('readFull')} value={formatCompact(detail.totals.cacheReadTokens)} title={formatTokens(detail.totals.cacheReadTokens)} sub={formatTokens(detail.totals.cacheReadTokens)} />
81
- <Stat tone={css.tonePink} name="write" label={t('writeFull')} value={formatCompact(detail.totals.cacheWriteTokens)} title={formatTokens(detail.totals.cacheWriteTokens)} sub={formatTokens(detail.totals.cacheWriteTokens)} />
82
- </div>
68
+ <Stat tone={css.toneBlue} name="requests" label={t('requestsCount')} value={formatTokens(detail.requests)} sub={`${t('failed')} ${failed} · ${failRate}%`} />
69
+ <Stat tone={css.tonePurple} name="tokens" label={t('tokens')} value={formatCompact(billed)} title={formatTokens(billed)} sub={`${formatTokens(billed)} tokens`} />
70
+ <Stat tone={css.toneTeal} name="input" label={t('inputFull')} value={formatCompact(detail.totals.uncachedInputTokens)} title={formatTokens(detail.totals.uncachedInputTokens)} sub={formatTokens(detail.totals.uncachedInputTokens)} />
71
+ <Stat tone={css.toneOrange} name="output" label={t('outputFull')} value={formatCompact(detail.totals.outputTokens)} title={formatTokens(detail.totals.outputTokens)} sub={formatTokens(detail.totals.outputTokens)} />
72
+ <Stat tone={css.toneGreen} name="hit" label={t('hit')} value={<Hit totals={detail.totals} />} sub={`${formatCompact(detail.totals.cacheReadTokens)} ${t('cacheHit')}`} />
73
+ <Stat tone={css.toneOrange} name="speed" label={t('speed')} value={speed(detail.tokensPerSecond)} sub={`${t('first')} ${seconds(detail.averageFirstTokenMs)}`} />
83
74
  </div>
84
75
  }
85
76
 
@@ -150,6 +141,11 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
150
141
  }
151
142
  group.rows.push(record)
152
143
  }
144
+ // Newest turn on top: groups order by the time of their most recent request,
145
+ // not by first appearance, so the live work leads and finished history trails
146
+ // (records arrive root-session first, subagents after, regardless of time).
147
+ const latest = (group: { rows: Row[] }): number => group.rows.reduce((max, row) => Math.max(max, row.startedAt), 0)
148
+ groups.sort((a, b) => latest(b) - latest(a))
153
149
  return <section className={css.card}><header className={css.cardHead}><h2>{t('records')} <span className={css.count}>{detail.records.length}</span></h2><button type="button" className={css.action} onClick={toggleAll}>{t(allOpen ? 'collapse' : 'expand')}</button></header>
154
150
  <div className={css.turnList}>{groups.map(group => {
155
151
  const open = allOpen !== opened.has(group.key)
@@ -175,8 +171,8 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
175
171
  </span>
176
172
  </button>
177
173
  <div id={`usage-turn-${encodeURIComponent(group.key)}`} className={css.turnBody} hidden={!open}>
178
- {/* Requests arrive in event order, so every turn lists its newest
179
- request first; turn groups themselves keep chronological order. */}
174
+ {/* Newest first inside and out: every turn lists its newest request
175
+ first, and turn groups themselves lead with the latest turn. */}
180
176
  {group.rows.slice().reverse().map(record => {
181
177
  const duration = record.finishedAt !== null ? record.finishedAt - record.startedAt : null
182
178
  const billed = billedTotal(record.totals)
@@ -207,8 +203,16 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
207
203
  }
208
204
 
209
205
  /** Pure presentation export also used by offline visual regression fixtures. */
210
- export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
211
- return <><p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} /><Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p></>
206
+ export function SessionUsageContent({ detail, t, updatedAt }: { detail: SessionUsageDetail; t: T; updatedAt?: number | null }): JSX.Element {
207
+ return <>
208
+ <p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
209
+ {/* The context window sits between the per-subagent/model tables and the
210
+ request records: it describes what the next request carries right now,
211
+ while everything above is spent-token history and everything below is
212
+ per-turn detail. */}
213
+ {detail.context ? <ContextWindowPanel context={detail.context} t={t} updatedAt={updatedAt} /> : null}
214
+ <Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p>
215
+ </>
212
216
  }
213
217
 
214
218
  export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
@@ -230,6 +234,6 @@ export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.E
230
234
  </header>
231
235
  {loading && <p className={css.empty} role="status">{t('loading')}</p>}
232
236
  {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={refresh}>{t('retry')}</button></div>}
233
- {detail && <SessionUsageContent detail={detail} t={t} />}
237
+ {detail && <SessionUsageContent detail={detail} t={t} updatedAt={updatedAt} />}
234
238
  </div></div>
235
239
  }