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.
- package/README.md +5 -2
- package/lib/client.js +626 -21
- package/lib/index.js +281 -4
- package/lib/types/client/ContextWindowPanel.d.ts +18 -0
- package/lib/types/client/SessionUsageView.d.ts +3 -2
- package/lib/types/client/context-window-core.d.ts +49 -0
- package/lib/types/client/session-usage-locales.d.ts +28 -0
- package/lib/types/context-window.d.ts +36 -0
- package/lib/types/store.d.ts +8 -1
- package/lib/types/wire.d.ts +50 -0
- package/package.json +1 -1
- package/src/client/ContextWindowPanel.module.css +77 -0
- package/src/client/ContextWindowPanel.tsx +222 -0
- package/src/client/SessionUsageView.module.css +81 -58
- package/src/client/SessionUsageView.tsx +13 -4
- package/src/client/context-window-core.ts +137 -0
- package/src/client/session-usage-locales.ts +36 -0
- package/src/collector.ts +4 -1
- package/src/context-window.ts +302 -0
- package/src/session-detail.ts +3 -2
- package/src/store.ts +9 -1
- package/src/wire.ts +53 -0
|
@@ -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
|
-
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
14
|
-
.liveDot { width:6px; height:6px; border-radius:50%; background
|
|
15
|
-
.asOf { color
|
|
16
|
-
.refresh { display:inline-flex; align-items:center; gap:5px; flex:none; color
|
|
17
|
-
.refresh:hover { color
|
|
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
|
|
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
|
|
22
|
-
.stat:hover { border-color
|
|
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
|
|
25
|
-
.statLabel { font-size:12px; font-weight:500; color
|
|
26
|
-
.statValue { display:block; font-size:21px; font-weight:650; letter-spacing:-0.4px; color
|
|
27
|
-
.statSub { display:block; margin-top:8px; padding-top:7px; border-top:1px solid
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
.
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
.
|
|
35
|
-
.
|
|
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
|
|
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
|
|
43
|
-
.table td { padding:13px 5px; text-align:right; border-top:1px solid
|
|
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
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.action:
|
|
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
|
|
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
|
|
73
|
-
.chevron { color
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
92
|
-
.requestTime { color
|
|
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
|
|
98
|
-
.routeText { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color
|
|
99
|
-
.effort { white-space:nowrap; color
|
|
100
|
-
.recordMeta { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; font-size:10px; color
|
|
101
|
-
.note { font-size:10px; line-height:1.7; color
|
|
102
|
-
.error { background
|
|
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
|
|
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
|
+
}
|