dsh-token-total 1.0.8 → 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
- * Six stat cards sit in one row (≤1050px: 3-up, ≤700px: 2-up) as quiet white
4
- * surfaces on a pale canvas: no accent edges or watermarks, a flat tone chip
5
- * leading each label, hierarchy from the type scale and a hairline above the
6
- * footnote. Request log folds by conversation turn. */
7
- .scroll { height:100%; min-height:0; overflow-y:auto; background:#f6f7f9; color:#252936; color-scheme:light; container-type:inline-size; }
8
- .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; }
9
17
  .page * { box-sizing:border-box; }
10
18
  /* Freshness header: live badge + last-read stamp on the left, manual refresh
11
19
  * pinned to the right edge. */
12
20
  .head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 12px; }
13
- .headStatus { display:flex; align-items:center; gap:7px; min-width:0; font-size:11px; color:#8790a3; }
14
- .liveDot { width:6px; height:6px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 3px #e5f6f0; flex-shrink:0; }
15
- .asOf { color:#9aa3b3; font-variant-numeric:tabular-nums; white-space:nowrap; }
16
- .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; }
17
- .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); }
18
26
  .refresh:disabled { opacity:.55; cursor:default; }
19
- .scope { color:#8790a3; font-size:11px; margin:0 0 14px; }
27
+ .scope { color:var(--dsw-alias-label-tertiary); font-size:11px; margin:0 0 14px; }
20
28
  .stats { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:8px; margin-bottom:16px; }
21
- .stat { min-width:0; position:relative; background:#fff; border:1px solid #e7eaf0; border-radius:12px; padding:12px 14px 11px; transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
22
- .stat:hover { border-color:#d9dfe9; box-shadow:0 14px 30px -22px rgba(23,32,54,.32); transform:translateY(-1px); }
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); }
23
31
  .statHead { display:flex; align-items:center; gap:5px; margin-bottom:6px; }
24
- .statIcon { display:flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:6px; color:var(--tone); background:var(--tone-soft); flex-shrink:0; }
25
- .statLabel { font-size:12px; font-weight:500; color:#7b8494; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
26
- .statValue { display:block; font-size:21px; font-weight:650; letter-spacing:-0.4px; color:#1b2130; white-space:nowrap; font-variant-numeric:tabular-nums; }
27
- .statSub { display:block; margin-top:8px; padding-top:7px; border-top:1px solid #f2f4f8; font-size:11px; color:#939aab; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
28
- .toneBlue { --tone:#4f79f6; --tone-soft:#eaf0fe; }
29
- .tonePurple { --tone:#8b5cf6; --tone-soft:#f1ebfe; }
30
- .toneGreen { --tone:#18a97b; --tone-soft:#e5f6f0; }
31
- .toneOrange { --tone:#e8892c; --tone-soft:#fcf1e4; }
32
- .toneTeal { --tone:#16a4b8; --tone-soft:#e4f5f8; }
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; }
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,58 +59,60 @@
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
118
  .stats { grid-template-columns:repeat(3,minmax(0,1fr)); }
@@ -131,3 +144,13 @@
131
144
  .stat { transition:none; }
132
145
  .stat:hover { transform:none; }
133
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
 
@@ -202,8 +203,16 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
202
203
  }
203
204
 
204
205
  /** Pure presentation export also used by offline visual regression fixtures. */
205
- export function SessionUsageContent({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
206
- 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
+ </>
207
216
  }
208
217
 
209
218
  export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.Element {
@@ -225,6 +234,6 @@ export function SessionUsageView({ sessionId, t }: SessionUsageViewProps): JSX.E
225
234
  </header>
226
235
  {loading && <p className={css.empty} role="status">{t('loading')}</p>}
227
236
  {error && <div className={css.error} role="status">{t('failedLoad')}<button type="button" className={css.action} onClick={refresh}>{t('retry')}</button></div>}
228
- {detail && <SessionUsageContent detail={detail} t={t} />}
237
+ {detail && <SessionUsageContent detail={detail} t={t} updatedAt={updatedAt} />}
229
238
  </div></div>
230
239
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Pure helpers behind the context-window panel: the treemap squarifier, the
3
+ * per-category roll-up the legend prints, and the occupancy reading. Kept
4
+ * separate from the component so both are testable without a DOM.
5
+ */
6
+ import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts'
7
+
8
+ /** Legend order, also the color assignment order (see the CSS module). */
9
+ export const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[] = [
10
+ 'system', 'tools', 'memory', 'files', 'results', 'conversation',
11
+ ]
12
+
13
+ /** One positioned treemap block in container pixels. */
14
+ export interface TreemapBox {
15
+ key: string
16
+ x: number
17
+ y: number
18
+ width: number
19
+ height: number
20
+ }
21
+
22
+ /** Per-category token totals in legend order, empty categories dropped. */
23
+ export function contextCategoryTotals(
24
+ items: readonly SessionContextItem[],
25
+ ): Array<{ key: SessionContextCategory; tokens: number }> {
26
+ const totals = new Map<SessionContextCategory, number>()
27
+ for (const item of items) totals.set(item.category, (totals.get(item.category) ?? 0) + item.tokens)
28
+ return CONTEXT_CATEGORY_ORDER
29
+ .map(key => ({ key, tokens: totals.get(key) ?? 0 }))
30
+ .filter(row => row.tokens > 0)
31
+ }
32
+
33
+ /** Window occupancy in whole percent, or null when the host never reported the
34
+ * model's window (the panel then says "unknown" instead of guessing one). */
35
+ export function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null {
36
+ if (contextWindow === null || !(contextWindow > 0) || !(usedTokens >= 0)) return null
37
+ return Math.min(100, Math.round(usedTokens / contextWindow * 100))
38
+ }
39
+
40
+ /** How much the replay estimate may drift from the billed prompt before the
41
+ * panel stops treating them as the same number (rounding, framing tokens). */
42
+ export const CONTEXT_ESTIMATE_TOLERANCE = 0.15
43
+
44
+ /** One reading of the snapshot for the headline: the billed prompt when the log
45
+ * has one (exact), else the replay estimate. `estimateDiffers` marks the case
46
+ * where the log carries text the model was never billed for — an upstream
47
+ * compression plugin, say — so the panel can explain a treemap that sums
48
+ * higher than the total. */
49
+ export interface ContextReading {
50
+ usedTokens: number
51
+ percent: number | null
52
+ estimateTokens: number
53
+ estimateDiffers: boolean
54
+ }
55
+
56
+ export function contextReading(context: SessionContextWindow): ContextReading {
57
+ const estimateTokens = context.totalTokens
58
+ const usedTokens = context.promptTokens ?? estimateTokens
59
+ const gap = estimateTokens > 0 ? Math.abs(estimateTokens - usedTokens) / Math.max(estimateTokens, usedTokens) : 0
60
+ return {
61
+ usedTokens,
62
+ percent: contextWindowPercent(usedTokens, context.contextWindow),
63
+ estimateTokens,
64
+ estimateDiffers: context.promptTokens !== null && gap > CONTEXT_ESTIMATE_TOLERANCE,
65
+ }
66
+ }
67
+
68
+ /** Aspect ratio of the worst tile in a row of `sum` area, given the strip's
69
+ * short side — the squarified-treemap cost function. */
70
+ function worstRatio(sum: number, max: number, min: number, side: number): number {
71
+ const squared = sum * sum
72
+ const sideSquared = side * side
73
+ return Math.max((sideSquared * max) / squared, squared / (sideSquared * min))
74
+ }
75
+
76
+ const round = (value: number): number => Math.round(value * 100) / 100
77
+
78
+ /**
79
+ * Squarified treemap (Bruls, Huizing & van Wijk): tile `width`×`height` with
80
+ * one block per positive weight, proportional to its tokens and as close to
81
+ * square as the greedy row packing allows. Blocks come back in the input's
82
+ * descending-weight order, covering the whole area.
83
+ */
84
+ export function treemapLayout(
85
+ rows: ReadonlyArray<{ key: string; tokens: number }>,
86
+ width: number,
87
+ height: number,
88
+ ): TreemapBox[] {
89
+ const ordered = rows.filter(row => row.tokens > 0 && Number.isFinite(row.tokens)).sort((a, b) => b.tokens - a.tokens)
90
+ if (ordered.length === 0 || !(width > 0) || !(height > 0)) return []
91
+ const total = ordered.reduce((sum, row) => sum + row.tokens, 0)
92
+ const scale = (width * height) / total
93
+ const areas = ordered.map(row => ({ key: row.key, area: row.tokens * scale }))
94
+ const boxes: TreemapBox[] = []
95
+ let x = 0
96
+ let y = 0
97
+ let w = width
98
+ let h = height
99
+ let index = 0
100
+ while (index < areas.length) {
101
+ const side = Math.min(w, h)
102
+ if (!(side > 0)) break
103
+ // Grow one row while the worst aspect ratio keeps improving.
104
+ let end = index + 1
105
+ let sum = areas[index]!.area
106
+ let max = sum
107
+ let min = sum
108
+ let worst = worstRatio(sum, max, min, side)
109
+ while (end < areas.length) {
110
+ const area = areas[end]!.area
111
+ const nextSum = sum + area
112
+ const nextMax = Math.max(max, area)
113
+ const nextMin = Math.min(min, area)
114
+ const nextWorst = worstRatio(nextSum, nextMax, nextMin, side)
115
+ if (nextWorst > worst) break
116
+ sum = nextSum
117
+ max = nextMax
118
+ min = nextMin
119
+ worst = nextWorst
120
+ end++
121
+ }
122
+ const thickness = sum / side
123
+ if (!(thickness > 0)) break
124
+ let offset = 0
125
+ for (let k = index; k < end; k++) {
126
+ const length = areas[k]!.area / thickness
127
+ const box = w >= h
128
+ ? { key: areas[k]!.key, x, y: y + offset, width: thickness, height: length }
129
+ : { key: areas[k]!.key, x: x + offset, y, width: length, height: thickness }
130
+ boxes.push({ key: box.key, x: round(box.x), y: round(box.y), width: round(box.width), height: round(box.height) })
131
+ offset += length
132
+ }
133
+ if (w >= h) { x += thickness; w -= thickness } else { y += thickness; h -= thickness }
134
+ index = end
135
+ }
136
+ return boxes
137
+ }