@aryacode/dsh-aryacode-polish 0.3.7 → 0.3.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/CHANGELOG.md +34 -0
- package/client/account.js +53 -60
- package/client/face.js +247 -75
- package/lib/client.js +301 -136
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.9] - 2026-10-27
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **The cost pill now looks like it belongs in the composer.** It was a foreign
|
|
8
|
+
widget next to the harness pills: two lines of text ("≈$0.538 partially
|
|
9
|
+
priced") in the row's flow. It now wears the same skin as the statistics pills
|
|
10
|
+
— the same 13 px-minus-one scale, 20 px line height, `1px 8px` padding, tertiary
|
|
11
|
+
label colour, tabular digits, a 14 px glyph, and `white-space: nowrap`, so it
|
|
12
|
+
reads as one line at the same size as its neighbours.
|
|
13
|
+
- The "partially priced" caveat moved out of the pill and into the card, where it
|
|
14
|
+
can be read. The pill keeps a single `*` marker for the same fact.
|
|
15
|
+
- The breakdown card is now the harness's own stat-dialog: same panel width
|
|
16
|
+
clamp, 16 px padding, `--dsw-radius-lg` corners, menu backdrop, prominent
|
|
17
|
+
elevation, 12/18 type, a two-column definition list with tabular figures, and
|
|
18
|
+
a portaled `position: fixed` panel measured from the pill so it cannot be
|
|
19
|
+
clipped by the composer scroller. It closes on outside click and on Escape.
|
|
20
|
+
|
|
21
|
+
## [0.3.8] - 2026-10-27
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **The balance line never showed a number.** It waited for the host account
|
|
26
|
+
bundle's own access grant before reading, on the theory that the grant proves
|
|
27
|
+
the call is allowed. That grant belongs to the bundle that owns the account
|
|
28
|
+
stream, so for a plugin it can simply never arrive — the read hit its timeout,
|
|
29
|
+
resolved to nothing, and the sidebar looked empty with no error anywhere. The
|
|
30
|
+
line now reads the account Remote directly inside
|
|
31
|
+
`ctx.inject(['remote', 'remote.account'], …)`, and the Host still decides: an
|
|
32
|
+
unauthorised call comes back refused and the line stays hidden.
|
|
33
|
+
- The read retries on its own (1.5 s, 5 s, 15 s, 30 s, and again whenever the
|
|
34
|
+
window regains focus), so a Shell that is still signing in at first paint no
|
|
35
|
+
longer leaves a permanently blank line.
|
|
36
|
+
|
|
3
37
|
## [0.3.7] - 2026-10-27
|
|
4
38
|
|
|
5
39
|
### Fixed
|
package/client/account.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reading the account balance from a plugin face.
|
|
3
3
|
*
|
|
4
|
-
* The account Remote methods are
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* The account Remote methods are the same ones the shipped account row uses. An
|
|
5
|
+
* earlier version waited for the host account bundle's own access grant before
|
|
6
|
+
* reading, on the theory that the grant proves the call is allowed. It does not:
|
|
7
|
+
* that grant belongs to the bundle that owns the account stream, and a plugin
|
|
8
|
+
* that waits for it can wait forever — the sidebar line then stays empty with no
|
|
9
|
+
* error anywhere, which is exactly what a user reports as "I don't see the
|
|
10
|
+
* balance".
|
|
11
|
+
*
|
|
12
|
+
* So this reads directly. The Host still decides: an unauthorised call comes back
|
|
13
|
+
* `{ ok: false }` (or throws) and this resolves to `undefined`. Every failure — no
|
|
14
|
+
* account namespace in this Shell, a refused call, a failed call — resolves to
|
|
15
|
+
* `undefined` instead of throwing, because a sidebar line is never worth breaking
|
|
16
|
+
* the shell over.
|
|
10
17
|
*
|
|
11
18
|
* This module is inlined into `lib/client.js` by `scripts/build-client.mjs` and
|
|
12
19
|
* imported directly by `test/account.spec.mjs`, so the tested source is the one
|
|
@@ -35,62 +42,48 @@ export function clientMetadata(version) {
|
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
|
-
*
|
|
45
|
+
* The account Remote namespace, or undefined when this Shell has none.
|
|
39
46
|
*
|
|
40
|
-
* @param ctx - the browser plugin context;
|
|
41
|
-
*
|
|
42
|
-
* @returns
|
|
47
|
+
* @param ctx - the browser plugin context; the balance surface is registered
|
|
48
|
+
* inside `ctx.inject(['remote', 'remote.account'], …)` so this normally resolves.
|
|
49
|
+
* @returns the namespace, or undefined.
|
|
43
50
|
*/
|
|
44
|
-
export function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
export function accountService(ctx) {
|
|
52
|
+
try {
|
|
53
|
+
const remote = ctx.remote !== undefined ? ctx.remote : ctx.get('remote')
|
|
54
|
+
const account = remote !== undefined && remote !== null ? remote.account : undefined
|
|
55
|
+
return account !== undefined && account !== null && typeof account.getBalance === 'function' ? account : undefined
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined
|
|
51
58
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
finish(value !== undefined && value.status === 'ready' ? value : undefined)
|
|
82
|
-
} catch {
|
|
83
|
-
finish(undefined)
|
|
84
|
-
}
|
|
85
|
-
})()
|
|
86
|
-
yield () => {
|
|
87
|
-
if (!settled) {
|
|
88
|
-
settled = true
|
|
89
|
-
clearTimeout(timer)
|
|
90
|
-
resolve(undefined)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the balance once.
|
|
63
|
+
*
|
|
64
|
+
* @param ctx - the browser plugin context.
|
|
65
|
+
* @param version - the version this build stamps into the account metadata.
|
|
66
|
+
* @param options - `timeoutMs` bounds a call that never settles.
|
|
67
|
+
* @returns the ready balance view, or undefined when there is no number to show.
|
|
68
|
+
*/
|
|
69
|
+
export async function readBalance(ctx, version, { timeoutMs = 15000 } = {}) {
|
|
70
|
+
const account = accountService(ctx)
|
|
71
|
+
if (account === undefined) return undefined
|
|
72
|
+
const call = (async () => {
|
|
73
|
+
const result = await account.getBalance(clientMetadata(version))
|
|
74
|
+
if (result === undefined || result === null || result.ok !== true) return undefined
|
|
75
|
+
const value = result.value
|
|
76
|
+
return value !== undefined && value !== null && value.status === 'ready' ? value : undefined
|
|
77
|
+
})().catch(() => undefined)
|
|
78
|
+
let timer
|
|
79
|
+
const timeout = new Promise(resolve => {
|
|
80
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs)
|
|
81
|
+
// A pending balance read must not keep a Node process alive during tests.
|
|
82
|
+
if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
|
|
83
|
+
})
|
|
84
|
+
try {
|
|
85
|
+
return await Promise.race([call, timeout])
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer)
|
|
95
88
|
}
|
|
96
89
|
}
|
package/client/face.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
const React = require('react')
|
|
18
|
+
const ReactDOM = require('react-dom')
|
|
18
19
|
|
|
19
20
|
/** Shared spacing for the page's prose. */
|
|
20
21
|
const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
|
|
@@ -184,23 +185,131 @@ function formatTokens(value) {
|
|
|
184
185
|
return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
|
|
185
186
|
}
|
|
186
187
|
|
|
187
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* One label/value line of the breakdown card.
|
|
190
|
+
*
|
|
191
|
+
* Native stat dialogs are a two-column `<dl>`; a flex div per row looks the same
|
|
192
|
+
* until the labels differ in width, so this emits the grid's own children.
|
|
193
|
+
*
|
|
194
|
+
* @param label - the row name.
|
|
195
|
+
* @param value - the reading.
|
|
196
|
+
* @param key - React key.
|
|
197
|
+
* @returns the `dt`/`dd` pair.
|
|
198
|
+
*/
|
|
188
199
|
function detailRow(label, value, key) {
|
|
189
|
-
return React.createElement(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
},
|
|
193
|
-
React.createElement('span', { style: { opacity: 0.75 } }, label),
|
|
194
|
-
React.createElement('span', null, value))
|
|
200
|
+
return React.createElement(React.Fragment, { key },
|
|
201
|
+
React.createElement('dt', null, label),
|
|
202
|
+
React.createElement('dd', null, value))
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
/** The small text under the card that keeps the number honest. */
|
|
198
206
|
const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
|
|
199
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The dock pill's skin, copied from the harness statistics pills
|
|
210
|
+
* (`StatsPills.module.css`) so this one sits in the row instead of looking
|
|
211
|
+
* foreign: same size, same tertiary color, same 14px glyph, never wrapped.
|
|
212
|
+
*/
|
|
213
|
+
const PILL_ANCHOR_STYLE = {
|
|
214
|
+
display: 'inline-flex',
|
|
215
|
+
minWidth: 0,
|
|
216
|
+
fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
|
|
217
|
+
lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The resting pill; hover and open states swap only background and color. */
|
|
221
|
+
const PILL_STYLE = {
|
|
222
|
+
display: 'inline-flex',
|
|
223
|
+
alignItems: 'center',
|
|
224
|
+
gap: '6px',
|
|
225
|
+
boxSizing: 'border-box',
|
|
226
|
+
maxWidth: '100%',
|
|
227
|
+
padding: '1px 8px',
|
|
228
|
+
border: 'none',
|
|
229
|
+
borderRadius: 'var(--dsw-radius-sm, 4px)',
|
|
230
|
+
background: 'transparent',
|
|
231
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
232
|
+
font: 'inherit',
|
|
233
|
+
fontVariantNumeric: 'tabular-nums',
|
|
234
|
+
lineHeight: 'inherit',
|
|
235
|
+
whiteSpace: 'nowrap',
|
|
236
|
+
cursor: 'pointer',
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The label half of the pill, cut with an ellipsis rather than wrapped. */
|
|
240
|
+
const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
|
|
241
|
+
|
|
242
|
+
/** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
|
|
243
|
+
const PANEL_STYLE = {
|
|
244
|
+
position: 'fixed',
|
|
245
|
+
zIndex: 1100,
|
|
246
|
+
boxSizing: 'border-box',
|
|
247
|
+
width: 'max-content',
|
|
248
|
+
minWidth: 'min(300px, calc(100vw - 24px))',
|
|
249
|
+
maxWidth: 'min(440px, calc(100vw - 24px))',
|
|
250
|
+
padding: '16px',
|
|
251
|
+
border: 0,
|
|
252
|
+
borderRadius: 'var(--dsw-radius-lg, 12px)',
|
|
253
|
+
background: 'var(--dsw-specific-menu, Canvas)',
|
|
254
|
+
backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
|
|
255
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
|
|
256
|
+
fontSize: '12px',
|
|
257
|
+
lineHeight: '18px',
|
|
258
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
259
|
+
cursor: 'default',
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Dialog heading row: section name left, headline value right. */
|
|
263
|
+
const PANEL_TITLE_STYLE = {
|
|
264
|
+
display: 'flex',
|
|
265
|
+
justifyContent: 'space-between',
|
|
266
|
+
gap: '16px',
|
|
267
|
+
marginBottom: '8px',
|
|
268
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
269
|
+
fontWeight: 500,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The rule under a section heading. */
|
|
273
|
+
const PANEL_RULE_STYLE = { marginBottom: '10px', borderTop: '0.5px solid var(--dsw-alias-border-l2, currentColor)' }
|
|
274
|
+
|
|
275
|
+
/** The two-column readings grid. */
|
|
276
|
+
const PANEL_DETAILS_STYLE = {
|
|
277
|
+
display: 'grid',
|
|
278
|
+
gridTemplateColumns: 'minmax(76px, auto) minmax(0, 1fr)',
|
|
279
|
+
gap: '6px 16px',
|
|
280
|
+
margin: 0,
|
|
281
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* A 14px coin glyph, the same weight as the harness pill icons. Drawn inline
|
|
286
|
+
* because a plugin face cannot require the icon package without adding it to the
|
|
287
|
+
* frozen module list.
|
|
288
|
+
* @returns the svg element.
|
|
289
|
+
*/
|
|
290
|
+
function CoinGlyph() {
|
|
291
|
+
return React.createElement('svg', {
|
|
292
|
+
width: 14,
|
|
293
|
+
height: 14,
|
|
294
|
+
viewBox: '0 0 16 16',
|
|
295
|
+
fill: 'none',
|
|
296
|
+
stroke: 'currentColor',
|
|
297
|
+
strokeWidth: 1.3,
|
|
298
|
+
'aria-hidden': true,
|
|
299
|
+
style: { flex: 'none' },
|
|
300
|
+
},
|
|
301
|
+
React.createElement('circle', { cx: 8, cy: 8, r: 6.2 }),
|
|
302
|
+
React.createElement('path', { d: 'M9.6 6.1c-.35-.5-1.85-.86-2.6-.3-.9.66-.35 1.5.75 1.72 1.1.22 2.1.5 1.85 1.5-.3 1.2-2.6 1.3-3.4.5' }),
|
|
303
|
+
React.createElement('path', { d: 'M8 4.4v7.2' }))
|
|
304
|
+
}
|
|
305
|
+
|
|
200
306
|
/**
|
|
201
307
|
* The composer-dock pill, and the breakdown card it opens.
|
|
308
|
+
*
|
|
202
309
|
* Reads the priced projection the Host registers, so no rate table or money
|
|
203
|
-
* arithmetic runs in the browser.
|
|
310
|
+
* arithmetic runs in the browser. The pill is skinned from the harness's own
|
|
311
|
+
* statistics pill so it joins that row instead of standing out of it.
|
|
312
|
+
*
|
|
204
313
|
* @param props - standard session props plus our lazy `configForms`.
|
|
205
314
|
* @returns the pill, or nothing while the feature is off or the session has no usage.
|
|
206
315
|
*/
|
|
@@ -208,22 +317,49 @@ function CostPill(props) {
|
|
|
208
317
|
const switched = useSwitch(props.configForms, 'sessionCost', true)
|
|
209
318
|
const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
|
|
210
319
|
const [open, setOpen] = React.useState(false)
|
|
320
|
+
const [hovered, setHovered] = React.useState(false)
|
|
321
|
+
const [pos, setPos] = React.useState(null)
|
|
211
322
|
const root = React.useRef(null)
|
|
323
|
+
const panel = React.useRef(null)
|
|
324
|
+
|
|
325
|
+
// Anchored like the native dialogs: fixed in the viewport, clamped to a 12px
|
|
326
|
+
// margin so a pill near the window edge cannot push the card off screen.
|
|
327
|
+
React.useEffect(() => {
|
|
328
|
+
if (!open) {
|
|
329
|
+
setPos(null)
|
|
330
|
+
return undefined
|
|
331
|
+
}
|
|
332
|
+
if (root.current === null || typeof window === 'undefined') return undefined
|
|
333
|
+
const rect = root.current.getBoundingClientRect()
|
|
334
|
+
const width = Math.min(440, window.innerWidth - 24)
|
|
335
|
+
setPos({
|
|
336
|
+
left: Math.max(12, Math.min(rect.left, window.innerWidth - 12 - width)),
|
|
337
|
+
bottom: Math.max(12, window.innerHeight - rect.top + 8),
|
|
338
|
+
})
|
|
339
|
+
return undefined
|
|
340
|
+
}, [open])
|
|
212
341
|
|
|
213
342
|
React.useEffect(() => {
|
|
214
343
|
if (!open) return undefined
|
|
215
|
-
const
|
|
216
|
-
|
|
344
|
+
const onPointer = event => {
|
|
345
|
+
const insidePill = root.current !== null && root.current.contains(event.target)
|
|
346
|
+
const insidePanel = panel.current !== null && panel.current.contains(event.target)
|
|
347
|
+
if (!insidePill && !insidePanel) setOpen(false)
|
|
348
|
+
}
|
|
349
|
+
const onKey = event => { if (event.key === 'Escape') setOpen(false) }
|
|
350
|
+
document.addEventListener('pointerdown', onPointer)
|
|
351
|
+
document.addEventListener('keydown', onKey)
|
|
352
|
+
return () => {
|
|
353
|
+
document.removeEventListener('pointerdown', onPointer)
|
|
354
|
+
document.removeEventListener('keydown', onKey)
|
|
217
355
|
}
|
|
218
|
-
document.addEventListener('pointerdown', close)
|
|
219
|
-
return () => document.removeEventListener('pointerdown', close)
|
|
220
356
|
}, [open])
|
|
221
357
|
|
|
222
358
|
if (!switched) return null
|
|
223
359
|
const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
|
|
224
360
|
if (tokens === 0) return null
|
|
225
361
|
const money = formatMoney(usage.costUsd)
|
|
226
|
-
const
|
|
362
|
+
const unpriced = usage.incomplete === true || money === ''
|
|
227
363
|
const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
|
|
228
364
|
const models = Array.isArray(usage.models) ? usage.models : []
|
|
229
365
|
|
|
@@ -245,7 +381,7 @@ function CostPill(props) {
|
|
|
245
381
|
style: {
|
|
246
382
|
display: 'flex',
|
|
247
383
|
height: '6px',
|
|
248
|
-
margin: '
|
|
384
|
+
margin: '0 0 12px',
|
|
249
385
|
borderRadius: '999px',
|
|
250
386
|
overflow: 'hidden',
|
|
251
387
|
background: 'color-mix(in srgb, CanvasText 10%, transparent)',
|
|
@@ -256,49 +392,27 @@ function CostPill(props) {
|
|
|
256
392
|
segment(usage.cacheWrite, 62, 'bar-cache-write'),
|
|
257
393
|
segment(usage.output, 82, 'bar-output'))
|
|
258
394
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
395
|
+
const reading = money === '' ? 'Not priced' : '≈' + money
|
|
396
|
+
const full = reading + (unpriced ? ' — partially priced: some usage has no published rate' : '')
|
|
397
|
+
const pillStyle = { ...PILL_STYLE, ...(hovered || open
|
|
398
|
+
? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12))', color: 'var(--dsw-alias-label-secondary, inherit)' }
|
|
399
|
+
: {}) }
|
|
400
|
+
|
|
401
|
+
const card = React.createElement('div', {
|
|
402
|
+
ref: panel,
|
|
403
|
+
role: 'dialog',
|
|
404
|
+
'aria-label': 'Estimated session cost',
|
|
405
|
+
'data-polish-cost-card': '',
|
|
406
|
+
style: { ...PANEL_STYLE, ...(pos === null ? { left: 12, bottom: 12, visibility: 'hidden' } : pos) },
|
|
262
407
|
},
|
|
263
|
-
React.createElement('
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
style: {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
cursor: 'pointer',
|
|
272
|
-
font: 'inherit',
|
|
273
|
-
padding: '0 2px',
|
|
274
|
-
opacity: 0.8,
|
|
275
|
-
},
|
|
276
|
-
}, label),
|
|
277
|
-
open
|
|
278
|
-
? React.createElement('div', {
|
|
279
|
-
style: {
|
|
280
|
-
position: 'absolute',
|
|
281
|
-
bottom: '100%',
|
|
282
|
-
left: 0,
|
|
283
|
-
marginBottom: '8px',
|
|
284
|
-
width: '320px',
|
|
285
|
-
padding: '14px',
|
|
286
|
-
borderRadius: '10px',
|
|
287
|
-
border: '1px solid color-mix(in srgb, CanvasText 16%, transparent)',
|
|
288
|
-
background: 'Canvas',
|
|
289
|
-
color: 'CanvasText',
|
|
290
|
-
boxShadow: '0 10px 30px color-mix(in srgb, CanvasText 18%, transparent)',
|
|
291
|
-
fontSize: '12px',
|
|
292
|
-
lineHeight: '1.5',
|
|
293
|
-
zIndex: 40,
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
React.createElement('div', {
|
|
297
|
-
style: { display: 'flex', justifyContent: 'space-between', gap: '16px', fontWeight: 500 },
|
|
298
|
-
},
|
|
299
|
-
React.createElement('span', null, 'Estimated session cost'),
|
|
300
|
-
React.createElement('span', null, label)),
|
|
301
|
-
bar,
|
|
408
|
+
React.createElement('div', { style: PANEL_TITLE_STYLE },
|
|
409
|
+
React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
|
|
410
|
+
React.createElement(CoinGlyph),
|
|
411
|
+
'Estimated session cost'),
|
|
412
|
+
React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, money === '' ? 'Not priced' : money)),
|
|
413
|
+
React.createElement('div', { style: PANEL_RULE_STYLE, 'aria-hidden': true }),
|
|
414
|
+
bar,
|
|
415
|
+
React.createElement('dl', { style: PANEL_DETAILS_STYLE },
|
|
302
416
|
detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
|
|
303
417
|
detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
|
|
304
418
|
detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
|
|
@@ -306,13 +420,39 @@ function CostPill(props) {
|
|
|
306
420
|
detailRow('Output', formatTokens(usage.output), 'output'),
|
|
307
421
|
detailRow('Model', modelName, 'model'),
|
|
308
422
|
detailRow('Provider', providerName, 'provider'),
|
|
309
|
-
detailRow('Requests', String(usage.requests ?? 0), 'requests'),
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
: null
|
|
423
|
+
detailRow('Requests', String(usage.requests ?? 0), 'requests')),
|
|
424
|
+
unpriced
|
|
425
|
+
? React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
|
|
426
|
+
'Partially priced: some usage in this session has no published rate, so this is a lower bound.')
|
|
427
|
+
: null,
|
|
428
|
+
React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, COST_FOOTNOTE))
|
|
429
|
+
|
|
430
|
+
return React.createElement('span', { ref: root, style: PILL_ANCHOR_STYLE, 'data-composer-stat': 'polish-cost' },
|
|
431
|
+
React.createElement('button', {
|
|
432
|
+
type: 'button',
|
|
433
|
+
className: undefined,
|
|
434
|
+
'aria-haspopup': 'dialog',
|
|
435
|
+
'aria-expanded': open,
|
|
436
|
+
'aria-label': full,
|
|
437
|
+
title: full,
|
|
438
|
+
onClick: () => setOpen(!open),
|
|
439
|
+
onMouseEnter: () => setHovered(true),
|
|
440
|
+
onMouseLeave: () => setHovered(false),
|
|
441
|
+
style: pillStyle,
|
|
442
|
+
},
|
|
443
|
+
React.createElement(CoinGlyph),
|
|
444
|
+
React.createElement('span', { style: PILL_LABEL_STYLE }, reading),
|
|
445
|
+
unpriced ? React.createElement('span', { style: { color: 'var(--dsw-alias-separator-primary, inherit)' } }, '*') : null),
|
|
446
|
+
open && typeof document !== 'undefined' ? ReactDOM.createPortal(card, document.body) : null)
|
|
314
447
|
}
|
|
315
448
|
|
|
449
|
+
/**
|
|
450
|
+
* How long to wait before trying the balance again, in milliseconds. A Shell that
|
|
451
|
+
* has not finished signing in answers the first read with nothing, and a line that
|
|
452
|
+
* gives up after one attempt would stay empty for the life of the page.
|
|
453
|
+
*/
|
|
454
|
+
const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
|
|
455
|
+
|
|
316
456
|
/**
|
|
317
457
|
* The remaining-balance line in the sidebar foot.
|
|
318
458
|
*
|
|
@@ -330,15 +470,43 @@ function BalanceLine(props) {
|
|
|
330
470
|
React.useEffect(() => {
|
|
331
471
|
if (!enabled || typeof read !== 'function') return undefined
|
|
332
472
|
let live = true
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
473
|
+
let attempt = 0
|
|
474
|
+
let timer
|
|
475
|
+
const look = () => {
|
|
476
|
+
Promise.resolve()
|
|
477
|
+
.then(() => read())
|
|
478
|
+
.then(view => {
|
|
479
|
+
if (!live) return
|
|
480
|
+
const next = walletOf(view)
|
|
481
|
+
if (next !== undefined) {
|
|
482
|
+
setWallet(next)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
if (attempt < BALANCE_RETRY_DELAYS.length) {
|
|
486
|
+
timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
|
|
487
|
+
attempt += 1
|
|
488
|
+
}
|
|
489
|
+
}, () => {
|
|
490
|
+
if (!live) return
|
|
491
|
+
if (attempt < BALANCE_RETRY_DELAYS.length) {
|
|
492
|
+
timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
|
|
493
|
+
attempt += 1
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
look()
|
|
498
|
+
// A window that was hidden through sign-in comes back with an answer the
|
|
499
|
+
// first attempt could not have had; retry from the start when it regains focus.
|
|
500
|
+
const onFocus = () => {
|
|
501
|
+
attempt = 0
|
|
502
|
+
clearTimeout(timer)
|
|
503
|
+
look()
|
|
504
|
+
}
|
|
505
|
+
if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
|
|
340
506
|
return () => {
|
|
341
507
|
live = false
|
|
508
|
+
clearTimeout(timer)
|
|
509
|
+
if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
|
|
342
510
|
}
|
|
343
511
|
}, [enabled, read])
|
|
344
512
|
if (!enabled) return null
|
|
@@ -392,12 +560,16 @@ return {
|
|
|
392
560
|
order: 2,
|
|
393
561
|
inject: () => ({ configForms }),
|
|
394
562
|
}, CostPill))
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
563
|
+
// The account Remote namespace is optional: a Shell with no account still gets
|
|
564
|
+
// the Configure page and the cost pill, and only loses the balance line.
|
|
565
|
+
ctx.inject(['remote', 'remote.account'], (accountCtx) => {
|
|
566
|
+
const loadBalance = () => readBalance(accountCtx, CLIENT_VERSION)
|
|
567
|
+
accountCtx.slots.inject('sidebar.footer.action', () => accountCtx.slots.register({
|
|
568
|
+
name: 'sidebar.footer.action',
|
|
569
|
+
id: 'polish-balance',
|
|
570
|
+
order: 3,
|
|
571
|
+
inject: () => ({ configForms, loadBalance }),
|
|
572
|
+
}, BalanceLine))
|
|
573
|
+
})
|
|
402
574
|
},
|
|
403
575
|
}
|
package/lib/client.js
CHANGED
|
@@ -23,7 +23,7 @@ window.__ModuleLoader__.load({
|
|
|
23
23
|
]
|
|
24
24
|
const ROW_ID = "polish"
|
|
25
25
|
const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
|
|
26
|
-
const CLIENT_VERSION = "0.3.
|
|
26
|
+
const CLIENT_VERSION = "0.3.9"
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* The two pure decisions behind the balance line: which wallet to show, and how
|
|
@@ -60,12 +60,19 @@ window.__ModuleLoader__.load({
|
|
|
60
60
|
/**
|
|
61
61
|
* Reading the account balance from a plugin face.
|
|
62
62
|
*
|
|
63
|
-
* The account Remote methods are
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
63
|
+
* The account Remote methods are the same ones the shipped account row uses. An
|
|
64
|
+
* earlier version waited for the host account bundle's own access grant before
|
|
65
|
+
* reading, on the theory that the grant proves the call is allowed. It does not:
|
|
66
|
+
* that grant belongs to the bundle that owns the account stream, and a plugin
|
|
67
|
+
* that waits for it can wait forever — the sidebar line then stays empty with no
|
|
68
|
+
* error anywhere, which is exactly what a user reports as "I don't see the
|
|
69
|
+
* balance".
|
|
70
|
+
*
|
|
71
|
+
* So this reads directly. The Host still decides: an unauthorised call comes back
|
|
72
|
+
* `{ ok: false }` (or throws) and this resolves to `undefined`. Every failure — no
|
|
73
|
+
* account namespace in this Shell, a refused call, a failed call — resolves to
|
|
74
|
+
* `undefined` instead of throwing, because a sidebar line is never worth breaking
|
|
75
|
+
* the shell over.
|
|
69
76
|
*
|
|
70
77
|
* This module is inlined into `lib/client.js` by `scripts/build-client.mjs` and
|
|
71
78
|
* imported directly by `test/account.spec.mjs`, so the tested source is the one
|
|
@@ -94,63 +101,49 @@ window.__ModuleLoader__.load({
|
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
/**
|
|
97
|
-
*
|
|
104
|
+
* The account Remote namespace, or undefined when this Shell has none.
|
|
98
105
|
*
|
|
99
|
-
* @param ctx - the browser plugin context;
|
|
100
|
-
*
|
|
101
|
-
* @returns
|
|
106
|
+
* @param ctx - the browser plugin context; the balance surface is registered
|
|
107
|
+
* inside `ctx.inject(['remote', 'remote.account'], …)` so this normally resolves.
|
|
108
|
+
* @returns the namespace, or undefined.
|
|
102
109
|
*/
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
function accountService(ctx) {
|
|
111
|
+
try {
|
|
112
|
+
const remote = ctx.remote !== undefined ? ctx.remote : ctx.get('remote')
|
|
113
|
+
const account = remote !== undefined && remote !== null ? remote.account : undefined
|
|
114
|
+
return account !== undefined && account !== null && typeof account.getBalance === 'function' ? account : undefined
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined
|
|
110
117
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
finish(value !== undefined && value.status === 'ready' ? value : undefined)
|
|
141
|
-
} catch {
|
|
142
|
-
finish(undefined)
|
|
143
|
-
}
|
|
144
|
-
})()
|
|
145
|
-
yield () => {
|
|
146
|
-
if (!settled) {
|
|
147
|
-
settled = true
|
|
148
|
-
clearTimeout(timer)
|
|
149
|
-
resolve(undefined)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
})
|
|
153
|
-
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the balance once.
|
|
122
|
+
*
|
|
123
|
+
* @param ctx - the browser plugin context.
|
|
124
|
+
* @param version - the version this build stamps into the account metadata.
|
|
125
|
+
* @param options - `timeoutMs` bounds a call that never settles.
|
|
126
|
+
* @returns the ready balance view, or undefined when there is no number to show.
|
|
127
|
+
*/
|
|
128
|
+
async function readBalance(ctx, version, { timeoutMs = 15000 } = {}) {
|
|
129
|
+
const account = accountService(ctx)
|
|
130
|
+
if (account === undefined) return undefined
|
|
131
|
+
const call = (async () => {
|
|
132
|
+
const result = await account.getBalance(clientMetadata(version))
|
|
133
|
+
if (result === undefined || result === null || result.ok !== true) return undefined
|
|
134
|
+
const value = result.value
|
|
135
|
+
return value !== undefined && value !== null && value.status === 'ready' ? value : undefined
|
|
136
|
+
})().catch(() => undefined)
|
|
137
|
+
let timer
|
|
138
|
+
const timeout = new Promise(resolve => {
|
|
139
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs)
|
|
140
|
+
// A pending balance read must not keep a Node process alive during tests.
|
|
141
|
+
if (typeof timer === 'object' && timer !== null && typeof timer.unref === 'function') timer.unref()
|
|
142
|
+
})
|
|
143
|
+
try {
|
|
144
|
+
return await Promise.race([call, timeout])
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timer)
|
|
154
147
|
}
|
|
155
148
|
}
|
|
156
149
|
|
|
@@ -171,6 +164,7 @@ window.__ModuleLoader__.load({
|
|
|
171
164
|
*/
|
|
172
165
|
|
|
173
166
|
const React = require('react')
|
|
167
|
+
const ReactDOM = require('react-dom')
|
|
174
168
|
|
|
175
169
|
/** Shared spacing for the page's prose. */
|
|
176
170
|
const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
|
|
@@ -340,23 +334,131 @@ window.__ModuleLoader__.load({
|
|
|
340
334
|
return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
|
|
341
335
|
}
|
|
342
336
|
|
|
343
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* One label/value line of the breakdown card.
|
|
339
|
+
*
|
|
340
|
+
* Native stat dialogs are a two-column `<dl>`; a flex div per row looks the same
|
|
341
|
+
* until the labels differ in width, so this emits the grid's own children.
|
|
342
|
+
*
|
|
343
|
+
* @param label - the row name.
|
|
344
|
+
* @param value - the reading.
|
|
345
|
+
* @param key - React key.
|
|
346
|
+
* @returns the `dt`/`dd` pair.
|
|
347
|
+
*/
|
|
344
348
|
function detailRow(label, value, key) {
|
|
345
|
-
return React.createElement(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
},
|
|
349
|
-
React.createElement('span', { style: { opacity: 0.75 } }, label),
|
|
350
|
-
React.createElement('span', null, value))
|
|
349
|
+
return React.createElement(React.Fragment, { key },
|
|
350
|
+
React.createElement('dt', null, label),
|
|
351
|
+
React.createElement('dd', null, value))
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
/** The small text under the card that keeps the number honest. */
|
|
354
355
|
const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
|
|
355
356
|
|
|
357
|
+
/**
|
|
358
|
+
* The dock pill's skin, copied from the harness statistics pills
|
|
359
|
+
* (`StatsPills.module.css`) so this one sits in the row instead of looking
|
|
360
|
+
* foreign: same size, same tertiary color, same 14px glyph, never wrapped.
|
|
361
|
+
*/
|
|
362
|
+
const PILL_ANCHOR_STYLE = {
|
|
363
|
+
display: 'inline-flex',
|
|
364
|
+
minWidth: 0,
|
|
365
|
+
fontSize: 'calc(var(--dsh-content-font-size-secondary, 13px) - 1px)',
|
|
366
|
+
lineHeight: 'calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The resting pill; hover and open states swap only background and color. */
|
|
370
|
+
const PILL_STYLE = {
|
|
371
|
+
display: 'inline-flex',
|
|
372
|
+
alignItems: 'center',
|
|
373
|
+
gap: '6px',
|
|
374
|
+
boxSizing: 'border-box',
|
|
375
|
+
maxWidth: '100%',
|
|
376
|
+
padding: '1px 8px',
|
|
377
|
+
border: 'none',
|
|
378
|
+
borderRadius: 'var(--dsw-radius-sm, 4px)',
|
|
379
|
+
background: 'transparent',
|
|
380
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
381
|
+
font: 'inherit',
|
|
382
|
+
fontVariantNumeric: 'tabular-nums',
|
|
383
|
+
lineHeight: 'inherit',
|
|
384
|
+
whiteSpace: 'nowrap',
|
|
385
|
+
cursor: 'pointer',
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The label half of the pill, cut with an ellipsis rather than wrapped. */
|
|
389
|
+
const PILL_LABEL_STYLE = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
|
|
390
|
+
|
|
391
|
+
/** The dialog surface, copied from the harness `stat-dialog.module.css` skin. */
|
|
392
|
+
const PANEL_STYLE = {
|
|
393
|
+
position: 'fixed',
|
|
394
|
+
zIndex: 1100,
|
|
395
|
+
boxSizing: 'border-box',
|
|
396
|
+
width: 'max-content',
|
|
397
|
+
minWidth: 'min(300px, calc(100vw - 24px))',
|
|
398
|
+
maxWidth: 'min(440px, calc(100vw - 24px))',
|
|
399
|
+
padding: '16px',
|
|
400
|
+
border: 0,
|
|
401
|
+
borderRadius: 'var(--dsw-radius-lg, 12px)',
|
|
402
|
+
background: 'var(--dsw-specific-menu, Canvas)',
|
|
403
|
+
backdropFilter: 'var(--dsw-menu-backdrop-filter, none)',
|
|
404
|
+
boxShadow: 'var(--dsw-elevation-prominent, 0 10px 30px rgba(0, 0, 0, 0.18))',
|
|
405
|
+
fontSize: '12px',
|
|
406
|
+
lineHeight: '18px',
|
|
407
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
408
|
+
cursor: 'default',
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Dialog heading row: section name left, headline value right. */
|
|
412
|
+
const PANEL_TITLE_STYLE = {
|
|
413
|
+
display: 'flex',
|
|
414
|
+
justifyContent: 'space-between',
|
|
415
|
+
gap: '16px',
|
|
416
|
+
marginBottom: '8px',
|
|
417
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
418
|
+
fontWeight: 500,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The rule under a section heading. */
|
|
422
|
+
const PANEL_RULE_STYLE = { marginBottom: '10px', borderTop: '0.5px solid var(--dsw-alias-border-l2, currentColor)' }
|
|
423
|
+
|
|
424
|
+
/** The two-column readings grid. */
|
|
425
|
+
const PANEL_DETAILS_STYLE = {
|
|
426
|
+
display: 'grid',
|
|
427
|
+
gridTemplateColumns: 'minmax(76px, auto) minmax(0, 1fr)',
|
|
428
|
+
gap: '6px 16px',
|
|
429
|
+
margin: 0,
|
|
430
|
+
color: 'var(--dsw-alias-label-tertiary, inherit)',
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* A 14px coin glyph, the same weight as the harness pill icons. Drawn inline
|
|
435
|
+
* because a plugin face cannot require the icon package without adding it to the
|
|
436
|
+
* frozen module list.
|
|
437
|
+
* @returns the svg element.
|
|
438
|
+
*/
|
|
439
|
+
function CoinGlyph() {
|
|
440
|
+
return React.createElement('svg', {
|
|
441
|
+
width: 14,
|
|
442
|
+
height: 14,
|
|
443
|
+
viewBox: '0 0 16 16',
|
|
444
|
+
fill: 'none',
|
|
445
|
+
stroke: 'currentColor',
|
|
446
|
+
strokeWidth: 1.3,
|
|
447
|
+
'aria-hidden': true,
|
|
448
|
+
style: { flex: 'none' },
|
|
449
|
+
},
|
|
450
|
+
React.createElement('circle', { cx: 8, cy: 8, r: 6.2 }),
|
|
451
|
+
React.createElement('path', { d: 'M9.6 6.1c-.35-.5-1.85-.86-2.6-.3-.9.66-.35 1.5.75 1.72 1.1.22 2.1.5 1.85 1.5-.3 1.2-2.6 1.3-3.4.5' }),
|
|
452
|
+
React.createElement('path', { d: 'M8 4.4v7.2' }))
|
|
453
|
+
}
|
|
454
|
+
|
|
356
455
|
/**
|
|
357
456
|
* The composer-dock pill, and the breakdown card it opens.
|
|
457
|
+
*
|
|
358
458
|
* Reads the priced projection the Host registers, so no rate table or money
|
|
359
|
-
* arithmetic runs in the browser.
|
|
459
|
+
* arithmetic runs in the browser. The pill is skinned from the harness's own
|
|
460
|
+
* statistics pill so it joins that row instead of standing out of it.
|
|
461
|
+
*
|
|
360
462
|
* @param props - standard session props plus our lazy `configForms`.
|
|
361
463
|
* @returns the pill, or nothing while the feature is off or the session has no usage.
|
|
362
464
|
*/
|
|
@@ -364,22 +466,49 @@ window.__ModuleLoader__.load({
|
|
|
364
466
|
const switched = useSwitch(props.configForms, 'sessionCost', true)
|
|
365
467
|
const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
|
|
366
468
|
const [open, setOpen] = React.useState(false)
|
|
469
|
+
const [hovered, setHovered] = React.useState(false)
|
|
470
|
+
const [pos, setPos] = React.useState(null)
|
|
367
471
|
const root = React.useRef(null)
|
|
472
|
+
const panel = React.useRef(null)
|
|
473
|
+
|
|
474
|
+
// Anchored like the native dialogs: fixed in the viewport, clamped to a 12px
|
|
475
|
+
// margin so a pill near the window edge cannot push the card off screen.
|
|
476
|
+
React.useEffect(() => {
|
|
477
|
+
if (!open) {
|
|
478
|
+
setPos(null)
|
|
479
|
+
return undefined
|
|
480
|
+
}
|
|
481
|
+
if (root.current === null || typeof window === 'undefined') return undefined
|
|
482
|
+
const rect = root.current.getBoundingClientRect()
|
|
483
|
+
const width = Math.min(440, window.innerWidth - 24)
|
|
484
|
+
setPos({
|
|
485
|
+
left: Math.max(12, Math.min(rect.left, window.innerWidth - 12 - width)),
|
|
486
|
+
bottom: Math.max(12, window.innerHeight - rect.top + 8),
|
|
487
|
+
})
|
|
488
|
+
return undefined
|
|
489
|
+
}, [open])
|
|
368
490
|
|
|
369
491
|
React.useEffect(() => {
|
|
370
492
|
if (!open) return undefined
|
|
371
|
-
const
|
|
372
|
-
|
|
493
|
+
const onPointer = event => {
|
|
494
|
+
const insidePill = root.current !== null && root.current.contains(event.target)
|
|
495
|
+
const insidePanel = panel.current !== null && panel.current.contains(event.target)
|
|
496
|
+
if (!insidePill && !insidePanel) setOpen(false)
|
|
497
|
+
}
|
|
498
|
+
const onKey = event => { if (event.key === 'Escape') setOpen(false) }
|
|
499
|
+
document.addEventListener('pointerdown', onPointer)
|
|
500
|
+
document.addEventListener('keydown', onKey)
|
|
501
|
+
return () => {
|
|
502
|
+
document.removeEventListener('pointerdown', onPointer)
|
|
503
|
+
document.removeEventListener('keydown', onKey)
|
|
373
504
|
}
|
|
374
|
-
document.addEventListener('pointerdown', close)
|
|
375
|
-
return () => document.removeEventListener('pointerdown', close)
|
|
376
505
|
}, [open])
|
|
377
506
|
|
|
378
507
|
if (!switched) return null
|
|
379
508
|
const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
|
|
380
509
|
if (tokens === 0) return null
|
|
381
510
|
const money = formatMoney(usage.costUsd)
|
|
382
|
-
const
|
|
511
|
+
const unpriced = usage.incomplete === true || money === ''
|
|
383
512
|
const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
|
|
384
513
|
const models = Array.isArray(usage.models) ? usage.models : []
|
|
385
514
|
|
|
@@ -401,7 +530,7 @@ window.__ModuleLoader__.load({
|
|
|
401
530
|
style: {
|
|
402
531
|
display: 'flex',
|
|
403
532
|
height: '6px',
|
|
404
|
-
margin: '
|
|
533
|
+
margin: '0 0 12px',
|
|
405
534
|
borderRadius: '999px',
|
|
406
535
|
overflow: 'hidden',
|
|
407
536
|
background: 'color-mix(in srgb, CanvasText 10%, transparent)',
|
|
@@ -412,49 +541,27 @@ window.__ModuleLoader__.load({
|
|
|
412
541
|
segment(usage.cacheWrite, 62, 'bar-cache-write'),
|
|
413
542
|
segment(usage.output, 82, 'bar-output'))
|
|
414
543
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
544
|
+
const reading = money === '' ? 'Not priced' : '≈' + money
|
|
545
|
+
const full = reading + (unpriced ? ' — partially priced: some usage has no published rate' : '')
|
|
546
|
+
const pillStyle = { ...PILL_STYLE, ...(hovered || open
|
|
547
|
+
? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.12))', color: 'var(--dsw-alias-label-secondary, inherit)' }
|
|
548
|
+
: {}) }
|
|
549
|
+
|
|
550
|
+
const card = React.createElement('div', {
|
|
551
|
+
ref: panel,
|
|
552
|
+
role: 'dialog',
|
|
553
|
+
'aria-label': 'Estimated session cost',
|
|
554
|
+
'data-polish-cost-card': '',
|
|
555
|
+
style: { ...PANEL_STYLE, ...(pos === null ? { left: 12, bottom: 12, visibility: 'hidden' } : pos) },
|
|
418
556
|
},
|
|
419
|
-
React.createElement('
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
style: {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
cursor: 'pointer',
|
|
428
|
-
font: 'inherit',
|
|
429
|
-
padding: '0 2px',
|
|
430
|
-
opacity: 0.8,
|
|
431
|
-
},
|
|
432
|
-
}, label),
|
|
433
|
-
open
|
|
434
|
-
? React.createElement('div', {
|
|
435
|
-
style: {
|
|
436
|
-
position: 'absolute',
|
|
437
|
-
bottom: '100%',
|
|
438
|
-
left: 0,
|
|
439
|
-
marginBottom: '8px',
|
|
440
|
-
width: '320px',
|
|
441
|
-
padding: '14px',
|
|
442
|
-
borderRadius: '10px',
|
|
443
|
-
border: '1px solid color-mix(in srgb, CanvasText 16%, transparent)',
|
|
444
|
-
background: 'Canvas',
|
|
445
|
-
color: 'CanvasText',
|
|
446
|
-
boxShadow: '0 10px 30px color-mix(in srgb, CanvasText 18%, transparent)',
|
|
447
|
-
fontSize: '12px',
|
|
448
|
-
lineHeight: '1.5',
|
|
449
|
-
zIndex: 40,
|
|
450
|
-
},
|
|
451
|
-
},
|
|
452
|
-
React.createElement('div', {
|
|
453
|
-
style: { display: 'flex', justifyContent: 'space-between', gap: '16px', fontWeight: 500 },
|
|
454
|
-
},
|
|
455
|
-
React.createElement('span', null, 'Estimated session cost'),
|
|
456
|
-
React.createElement('span', null, label)),
|
|
457
|
-
bar,
|
|
557
|
+
React.createElement('div', { style: PANEL_TITLE_STYLE },
|
|
558
|
+
React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
|
|
559
|
+
React.createElement(CoinGlyph),
|
|
560
|
+
'Estimated session cost'),
|
|
561
|
+
React.createElement('span', { style: { fontVariantNumeric: 'tabular-nums' } }, money === '' ? 'Not priced' : money)),
|
|
562
|
+
React.createElement('div', { style: PANEL_RULE_STYLE, 'aria-hidden': true }),
|
|
563
|
+
bar,
|
|
564
|
+
React.createElement('dl', { style: PANEL_DETAILS_STYLE },
|
|
458
565
|
detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
|
|
459
566
|
detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
|
|
460
567
|
detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
|
|
@@ -462,13 +569,39 @@ window.__ModuleLoader__.load({
|
|
|
462
569
|
detailRow('Output', formatTokens(usage.output), 'output'),
|
|
463
570
|
detailRow('Model', modelName, 'model'),
|
|
464
571
|
detailRow('Provider', providerName, 'provider'),
|
|
465
|
-
detailRow('Requests', String(usage.requests ?? 0), 'requests'),
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
: null
|
|
572
|
+
detailRow('Requests', String(usage.requests ?? 0), 'requests')),
|
|
573
|
+
unpriced
|
|
574
|
+
? React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } },
|
|
575
|
+
'Partially priced: some usage in this session has no published rate, so this is a lower bound.')
|
|
576
|
+
: null,
|
|
577
|
+
React.createElement('p', { style: { margin: '10px 0 0', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, COST_FOOTNOTE))
|
|
578
|
+
|
|
579
|
+
return React.createElement('span', { ref: root, style: PILL_ANCHOR_STYLE, 'data-composer-stat': 'polish-cost' },
|
|
580
|
+
React.createElement('button', {
|
|
581
|
+
type: 'button',
|
|
582
|
+
className: undefined,
|
|
583
|
+
'aria-haspopup': 'dialog',
|
|
584
|
+
'aria-expanded': open,
|
|
585
|
+
'aria-label': full,
|
|
586
|
+
title: full,
|
|
587
|
+
onClick: () => setOpen(!open),
|
|
588
|
+
onMouseEnter: () => setHovered(true),
|
|
589
|
+
onMouseLeave: () => setHovered(false),
|
|
590
|
+
style: pillStyle,
|
|
591
|
+
},
|
|
592
|
+
React.createElement(CoinGlyph),
|
|
593
|
+
React.createElement('span', { style: PILL_LABEL_STYLE }, reading),
|
|
594
|
+
unpriced ? React.createElement('span', { style: { color: 'var(--dsw-alias-separator-primary, inherit)' } }, '*') : null),
|
|
595
|
+
open && typeof document !== 'undefined' ? ReactDOM.createPortal(card, document.body) : null)
|
|
470
596
|
}
|
|
471
597
|
|
|
598
|
+
/**
|
|
599
|
+
* How long to wait before trying the balance again, in milliseconds. A Shell that
|
|
600
|
+
* has not finished signing in answers the first read with nothing, and a line that
|
|
601
|
+
* gives up after one attempt would stay empty for the life of the page.
|
|
602
|
+
*/
|
|
603
|
+
const BALANCE_RETRY_DELAYS = [1500, 5000, 15000, 30000]
|
|
604
|
+
|
|
472
605
|
/**
|
|
473
606
|
* The remaining-balance line in the sidebar foot.
|
|
474
607
|
*
|
|
@@ -486,15 +619,43 @@ window.__ModuleLoader__.load({
|
|
|
486
619
|
React.useEffect(() => {
|
|
487
620
|
if (!enabled || typeof read !== 'function') return undefined
|
|
488
621
|
let live = true
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
622
|
+
let attempt = 0
|
|
623
|
+
let timer
|
|
624
|
+
const look = () => {
|
|
625
|
+
Promise.resolve()
|
|
626
|
+
.then(() => read())
|
|
627
|
+
.then(view => {
|
|
628
|
+
if (!live) return
|
|
629
|
+
const next = walletOf(view)
|
|
630
|
+
if (next !== undefined) {
|
|
631
|
+
setWallet(next)
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
if (attempt < BALANCE_RETRY_DELAYS.length) {
|
|
635
|
+
timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
|
|
636
|
+
attempt += 1
|
|
637
|
+
}
|
|
638
|
+
}, () => {
|
|
639
|
+
if (!live) return
|
|
640
|
+
if (attempt < BALANCE_RETRY_DELAYS.length) {
|
|
641
|
+
timer = setTimeout(look, BALANCE_RETRY_DELAYS[attempt])
|
|
642
|
+
attempt += 1
|
|
643
|
+
}
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
look()
|
|
647
|
+
// A window that was hidden through sign-in comes back with an answer the
|
|
648
|
+
// first attempt could not have had; retry from the start when it regains focus.
|
|
649
|
+
const onFocus = () => {
|
|
650
|
+
attempt = 0
|
|
651
|
+
clearTimeout(timer)
|
|
652
|
+
look()
|
|
653
|
+
}
|
|
654
|
+
if (typeof window !== 'undefined') window.addEventListener('focus', onFocus)
|
|
496
655
|
return () => {
|
|
497
656
|
live = false
|
|
657
|
+
clearTimeout(timer)
|
|
658
|
+
if (typeof window !== 'undefined') window.removeEventListener('focus', onFocus)
|
|
498
659
|
}
|
|
499
660
|
}, [enabled, read])
|
|
500
661
|
if (!enabled) return null
|
|
@@ -548,13 +709,17 @@ window.__ModuleLoader__.load({
|
|
|
548
709
|
order: 2,
|
|
549
710
|
inject: () => ({ configForms }),
|
|
550
711
|
}, CostPill))
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
712
|
+
// The account Remote namespace is optional: a Shell with no account still gets
|
|
713
|
+
// the Configure page and the cost pill, and only loses the balance line.
|
|
714
|
+
ctx.inject(['remote', 'remote.account'], (accountCtx) => {
|
|
715
|
+
const loadBalance = () => readBalance(accountCtx, CLIENT_VERSION)
|
|
716
|
+
accountCtx.slots.inject('sidebar.footer.action', () => accountCtx.slots.register({
|
|
717
|
+
name: 'sidebar.footer.action',
|
|
718
|
+
id: 'polish-balance',
|
|
719
|
+
order: 3,
|
|
720
|
+
inject: () => ({ configForms, loadBalance }),
|
|
721
|
+
}, BalanceLine))
|
|
722
|
+
})
|
|
558
723
|
},
|
|
559
724
|
}
|
|
560
725
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryacode/dsh-aryacode-polish",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|