@aryacode/dsh-aryacode-polish 0.0.0-stage → 0.3.4
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 +165 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/client/account.js +96 -0
- package/client/face.js +403 -0
- package/client/money.js +31 -0
- package/client/switches.json +17 -0
- package/cordis.patch.yml +17 -0
- package/docs/parked-spellcheck.md +105 -0
- package/entries/config.js +61 -0
- package/entries/cost.js +273 -0
- package/entries/polish.js +57 -0
- package/entries/registry.js +73 -0
- package/entries/usage.js +265 -0
- package/lib/client.js +561 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
package/client/face.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AryaCode Polish — the browser face.
|
|
3
|
+
*
|
|
4
|
+
* This file is deliberately not a module. `scripts/build-client.mjs` wraps it in
|
|
5
|
+
* the Shell's `__ModuleLoader__.load({ id, factory })` factory and prepends the
|
|
6
|
+
* switch list from `client/switches.json`, so `require` is the Shell's frozen
|
|
7
|
+
* module table and never Node's `require`. Keep `require(...)` limited to names
|
|
8
|
+
* the Shell answers; anything else must be inlined here.
|
|
9
|
+
*
|
|
10
|
+
* The Configure page is the point of this package: one boolean per feature, on
|
|
11
|
+
* by default, and the host half of a feature is disposed when its switch turns
|
|
12
|
+
* off. The page reads the row's live config form, so adding a feature needs no
|
|
13
|
+
* second registration here — one line in `client/switches.json` and a matching
|
|
14
|
+
* entry in `entries/registry.js`, and the tests fail if the two disagree.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const React = require('react')
|
|
18
|
+
|
|
19
|
+
/** Shared spacing for the page's prose. */
|
|
20
|
+
const SPACING = { margin: '0 0 10px', opacity: 0.85, lineHeight: '1.5' }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read one switch out of a configuration object.
|
|
24
|
+
* @param value - the row's configuration object, when it has loaded.
|
|
25
|
+
* @param entry - the declared switch.
|
|
26
|
+
* @returns the boolean the switch holds, falling back to its declared default.
|
|
27
|
+
*/
|
|
28
|
+
function switchValue(value, entry) {
|
|
29
|
+
const current = value === undefined || value === null ? undefined : value[entry.key]
|
|
30
|
+
if (typeof current === 'boolean') return current
|
|
31
|
+
return entry.defaultOn !== false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Subscribe to one switch of this row's live configuration.
|
|
36
|
+
* @param configForms - the Shell's config-form service, or a getter for it.
|
|
37
|
+
* @param key - the switch to read.
|
|
38
|
+
* @param fallback - the value to use while the row's configuration has not loaded.
|
|
39
|
+
* @returns the switch's boolean value.
|
|
40
|
+
*/
|
|
41
|
+
function useSwitch(configForms, key, fallback) {
|
|
42
|
+
const resolved = typeof configForms === 'function' ? configForms() : configForms
|
|
43
|
+
const live = resolved === undefined ? undefined : resolved.get(ROW_ID)
|
|
44
|
+
const subscribe = React.useCallback(
|
|
45
|
+
listener => (live === undefined ? () => {} : live.subscribe(listener)),
|
|
46
|
+
[live],
|
|
47
|
+
)
|
|
48
|
+
const snapshot = React.useCallback(
|
|
49
|
+
() => (live === undefined ? undefined : live.getSnapshot()),
|
|
50
|
+
[live],
|
|
51
|
+
)
|
|
52
|
+
const state = React.useSyncExternalStore(subscribe, snapshot)
|
|
53
|
+
const value = state?.value
|
|
54
|
+
const current = value === undefined || value === null ? undefined : value[key]
|
|
55
|
+
return typeof current === 'boolean' ? current : fallback
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The row's Configure page and its one-line summary.
|
|
60
|
+
* @param props - the page's `view`, the Host's `form`, and our lazy `configForms`.
|
|
61
|
+
* @returns the page node, the summary node, or a status line.
|
|
62
|
+
*/
|
|
63
|
+
function PolishConfig(props) {
|
|
64
|
+
const { form, view } = props
|
|
65
|
+
|
|
66
|
+
// Resolved lazily: the Settings page's own client half may load after this face.
|
|
67
|
+
const configForms = typeof props.configForms === 'function' ? props.configForms() : props.configForms
|
|
68
|
+
const live = configForms === undefined ? undefined : configForms.get(ROW_ID)
|
|
69
|
+
const subscribe = React.useCallback(
|
|
70
|
+
listener => (live === undefined ? () => {} : live.subscribe(listener)),
|
|
71
|
+
[live],
|
|
72
|
+
)
|
|
73
|
+
const snapshot = React.useCallback(
|
|
74
|
+
() => (live === undefined ? form?.state : live.getSnapshot()),
|
|
75
|
+
[live, form],
|
|
76
|
+
)
|
|
77
|
+
const state = React.useSyncExternalStore(subscribe, snapshot)
|
|
78
|
+
const [draft, setDraft] = React.useState(null)
|
|
79
|
+
const [saving, setSaving] = React.useState(false)
|
|
80
|
+
const [notice, setNotice] = React.useState('')
|
|
81
|
+
|
|
82
|
+
const count = SWITCHES.length
|
|
83
|
+
const summary = count === 0 ? 'No switch registered yet.' : count === 1 ? '1 switch.' : count + ' switches.'
|
|
84
|
+
|
|
85
|
+
if (view === 'summary') return React.createElement('span', null, summary)
|
|
86
|
+
|
|
87
|
+
if (count === 0) {
|
|
88
|
+
return React.createElement('div', null,
|
|
89
|
+
React.createElement('p', { style: SPACING },
|
|
90
|
+
'No switch is registered yet. This page grows one checkbox per feature as features land; '
|
|
91
|
+
+ 'there is nothing here that can be toggled into a half-installed state.'),
|
|
92
|
+
React.createElement('p', { style: SPACING },
|
|
93
|
+
'A feature needs two lines: an entry in ',
|
|
94
|
+
React.createElement('code', null, 'entries/registry.js'),
|
|
95
|
+
' that declares its switch and host half, and a matching entry in ',
|
|
96
|
+
React.createElement('code', null, 'client/switches.json'),
|
|
97
|
+
' that names it here. The tests fail if the two disagree.'),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (state === undefined) {
|
|
102
|
+
return React.createElement('p', { style: SPACING }, 'This row does not expose its configuration to the Plugins page.')
|
|
103
|
+
}
|
|
104
|
+
const value = state.value
|
|
105
|
+
if (value === undefined) {
|
|
106
|
+
return React.createElement('p', { style: SPACING },
|
|
107
|
+
state.status === 'unavailable' ? 'This row has no configuration to change.' : 'Loading configuration…')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const current = draft === null ? value : draft
|
|
111
|
+
const dirty = SWITCHES.some(entry => switchValue(current, entry) !== switchValue(value, entry))
|
|
112
|
+
const writable = state.writable === true
|
|
113
|
+
const setSwitch = (key, next) => {
|
|
114
|
+
setDraft({ ...current, [key]: next })
|
|
115
|
+
setNotice('')
|
|
116
|
+
}
|
|
117
|
+
const save = async () => {
|
|
118
|
+
if (saving || !dirty) return
|
|
119
|
+
setSaving(true)
|
|
120
|
+
setNotice('')
|
|
121
|
+
try {
|
|
122
|
+
const ops = SWITCHES.map(entry => ({ op: 'set', path: [entry.key], value: switchValue(current, entry) }))
|
|
123
|
+
const owner = live === undefined ? form : live
|
|
124
|
+
const accepted = await owner.mutate(ops, state.revision)
|
|
125
|
+
if (accepted) {
|
|
126
|
+
setDraft(null)
|
|
127
|
+
setNotice('Saved. A feature switched off stops running now, not after a restart.')
|
|
128
|
+
} else {
|
|
129
|
+
setNotice('The Host refused the change.')
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
setNotice(error instanceof Error ? error.message : String(error))
|
|
133
|
+
}
|
|
134
|
+
setSaving(false)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return React.createElement('div', { style: { display: 'grid', gap: '16px' } },
|
|
138
|
+
React.createElement('p', { style: SPACING },
|
|
139
|
+
'Every switch is additive and on by default. Switching one off disposes that feature only — '
|
|
140
|
+
+ 'it stops costing the app instead of merely hiding.'),
|
|
141
|
+
React.createElement('div', { style: { display: 'grid', gap: '14px' } },
|
|
142
|
+
SWITCHES.map(entry => React.createElement('label', {
|
|
143
|
+
key: entry.key,
|
|
144
|
+
style: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '10px', alignItems: 'start', cursor: writable ? 'pointer' : 'default' },
|
|
145
|
+
},
|
|
146
|
+
React.createElement('input', {
|
|
147
|
+
type: 'checkbox',
|
|
148
|
+
checked: switchValue(current, entry),
|
|
149
|
+
disabled: !writable || saving,
|
|
150
|
+
onChange: event => setSwitch(entry.key, event.target.checked),
|
|
151
|
+
}),
|
|
152
|
+
React.createElement('span', null,
|
|
153
|
+
React.createElement('span', { style: { display: 'block', fontWeight: 600 } }, entry.label),
|
|
154
|
+
React.createElement('span', { style: { display: 'block', opacity: 0.8, lineHeight: '1.45' } }, entry.description),
|
|
155
|
+
))),
|
|
156
|
+
),
|
|
157
|
+
React.createElement('div', { style: { display: 'flex', gap: '10px', alignItems: 'center' } },
|
|
158
|
+
React.createElement('button', {
|
|
159
|
+
type: 'button',
|
|
160
|
+
disabled: !writable || saving || !dirty,
|
|
161
|
+
onClick: save,
|
|
162
|
+
}, saving ? 'Saving…' : 'Save'),
|
|
163
|
+
React.createElement('span', { style: { opacity: 0.8 } },
|
|
164
|
+
notice !== '' ? notice : writable ? '' : 'This row is read-only in the current composition.')),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Money from the wire view, which already did the arithmetic host-side. */
|
|
169
|
+
function formatMoney(value) {
|
|
170
|
+
const amount = Number(value)
|
|
171
|
+
if (!Number.isFinite(amount) || amount <= 0) return ''
|
|
172
|
+
// Pennies matter at the top and single tokens matter at the bottom: a small
|
|
173
|
+
// turn really does cost a fraction of a cent, and "$0" would hide it.
|
|
174
|
+
const fixed = amount >= 1 ? amount.toFixed(2)
|
|
175
|
+
: amount >= 0.01 ? amount.toFixed(3)
|
|
176
|
+
: amount.toPrecision(2)
|
|
177
|
+
const text = fixed.includes('e') ? amount.toFixed(8) : fixed
|
|
178
|
+
return '$' + text.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Token counts, grouped, the way the harness pill next to ours reads. */
|
|
182
|
+
function formatTokens(value) {
|
|
183
|
+
const count = Number(value)
|
|
184
|
+
return Number.isFinite(count) ? new Intl.NumberFormat('en-US').format(count) + ' tok' : ''
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One label/value line of the breakdown card. */
|
|
188
|
+
function detailRow(label, value, key) {
|
|
189
|
+
return React.createElement('div', {
|
|
190
|
+
key,
|
|
191
|
+
style: { display: 'flex', justifyContent: 'space-between', gap: '16px', padding: '2px 0' },
|
|
192
|
+
},
|
|
193
|
+
React.createElement('span', { style: { opacity: 0.75 } }, label),
|
|
194
|
+
React.createElement('span', null, value))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The small text under the card that keeps the number honest. */
|
|
198
|
+
const COST_FOOTNOTE = 'Based on recorded usage and published rates. Your provider\'s bill is authoritative.'
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The composer-dock pill, and the breakdown card it opens.
|
|
202
|
+
* Reads the priced projection the Host registers, so no rate table or money
|
|
203
|
+
* arithmetic runs in the browser.
|
|
204
|
+
* @param props - standard session props plus our lazy `configForms`.
|
|
205
|
+
* @returns the pill, or nothing while the feature is off or the session has no usage.
|
|
206
|
+
*/
|
|
207
|
+
function CostPill(props) {
|
|
208
|
+
const switched = useSwitch(props.configForms, 'sessionCost', true)
|
|
209
|
+
const usage = typeof props.useProjection === 'function' ? props.useProjection('polishCost') : undefined
|
|
210
|
+
const [open, setOpen] = React.useState(false)
|
|
211
|
+
const root = React.useRef(null)
|
|
212
|
+
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
if (!open) return undefined
|
|
215
|
+
const close = event => {
|
|
216
|
+
if (root.current !== null && !root.current.contains(event.target)) setOpen(false)
|
|
217
|
+
}
|
|
218
|
+
document.addEventListener('pointerdown', close)
|
|
219
|
+
return () => document.removeEventListener('pointerdown', close)
|
|
220
|
+
}, [open])
|
|
221
|
+
|
|
222
|
+
if (!switched) return null
|
|
223
|
+
const tokens = typeof usage?.tokens === 'number' ? usage.tokens : 0
|
|
224
|
+
if (tokens === 0) return null
|
|
225
|
+
const money = formatMoney(usage.costUsd)
|
|
226
|
+
const label = money === '' ? 'Not priced' : (usage.incomplete ? '≈' + money + ' partially priced' : '≈' + money)
|
|
227
|
+
const cacheHit = tokens === 0 ? 0 : Math.round((usage.cacheRead ?? 0) / tokens * 100)
|
|
228
|
+
const models = Array.isArray(usage.models) ? usage.models : []
|
|
229
|
+
|
|
230
|
+
const modelName = models.length === 0 ? 'unknown'
|
|
231
|
+
: models.length === 1 ? models[0].model
|
|
232
|
+
: models.length + ' models'
|
|
233
|
+
const providerName = models.length === 0 ? 'unknown'
|
|
234
|
+
: models.length === 1 ? models[0].provider
|
|
235
|
+
: models.length + ' providers'
|
|
236
|
+
const segment = (value, shade, key) => {
|
|
237
|
+
const countValue = Number(value ?? 0)
|
|
238
|
+
if (!Number.isFinite(countValue) || countValue <= 0) return null
|
|
239
|
+
return React.createElement('div', {
|
|
240
|
+
key,
|
|
241
|
+
style: { flexGrow: countValue, background: 'color-mix(in srgb, CanvasText ' + shade + '%, transparent)' },
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
const bar = React.createElement('div', {
|
|
245
|
+
style: {
|
|
246
|
+
display: 'flex',
|
|
247
|
+
height: '6px',
|
|
248
|
+
margin: '10px 0 12px',
|
|
249
|
+
borderRadius: '999px',
|
|
250
|
+
overflow: 'hidden',
|
|
251
|
+
background: 'color-mix(in srgb, CanvasText 10%, transparent)',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
segment(usage.cacheRead, 22, 'bar-cached'),
|
|
255
|
+
segment(usage.uncached, 48, 'bar-uncached'),
|
|
256
|
+
segment(usage.cacheWrite, 62, 'bar-cache-write'),
|
|
257
|
+
segment(usage.output, 82, 'bar-output'))
|
|
258
|
+
|
|
259
|
+
return React.createElement('div', {
|
|
260
|
+
ref: root,
|
|
261
|
+
style: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
|
|
262
|
+
},
|
|
263
|
+
React.createElement('button', {
|
|
264
|
+
type: 'button',
|
|
265
|
+
onClick: () => setOpen(!open),
|
|
266
|
+
title: 'Estimated session cost',
|
|
267
|
+
style: {
|
|
268
|
+
background: 'transparent',
|
|
269
|
+
border: 'none',
|
|
270
|
+
color: 'inherit',
|
|
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,
|
|
302
|
+
detailRow('Cache hit', cacheHit + '%', 'cache-hit'),
|
|
303
|
+
detailRow('Uncached input', formatTokens(usage.uncached), 'uncached'),
|
|
304
|
+
detailRow('Cached input', formatTokens(usage.cacheRead), 'cached'),
|
|
305
|
+
(usage.cacheWrite ?? 0) > 0 ? detailRow('Cache write', formatTokens(usage.cacheWrite), 'cache-write') : null,
|
|
306
|
+
detailRow('Output', formatTokens(usage.output), 'output'),
|
|
307
|
+
detailRow('Model', modelName, 'model'),
|
|
308
|
+
detailRow('Provider', providerName, 'provider'),
|
|
309
|
+
detailRow('Requests', String(usage.requests ?? 0), 'requests'),
|
|
310
|
+
React.createElement('div', { style: { borderTop: '1px solid color-mix(in srgb, CanvasText 12%, transparent)', margin: '8px 0' } }),
|
|
311
|
+
detailRow('Estimated session cost', money === '' ? 'Not priced' : money, 'cost'),
|
|
312
|
+
React.createElement('p', { style: { margin: '10px 0 0', opacity: 0.7, fontSize: '11px' } }, COST_FOOTNOTE))
|
|
313
|
+
: null)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The remaining-balance line in the sidebar foot.
|
|
318
|
+
*
|
|
319
|
+
* It renders nothing at all when the switch is off, the account exposes no
|
|
320
|
+
* wallet, or the read was refused — an empty line is better than a line that
|
|
321
|
+
* claims a number the Host did not give us.
|
|
322
|
+
*
|
|
323
|
+
* @param props - the slot's `wide` flag, our lazy `configForms`, and the balance reader.
|
|
324
|
+
* @returns the balance line, or null.
|
|
325
|
+
*/
|
|
326
|
+
function BalanceLine(props) {
|
|
327
|
+
const enabled = useSwitch(props.configForms, 'accountBalance', true)
|
|
328
|
+
const [wallet, setWallet] = React.useState(undefined)
|
|
329
|
+
const read = props.loadBalance
|
|
330
|
+
React.useEffect(() => {
|
|
331
|
+
if (!enabled || typeof read !== 'function') return undefined
|
|
332
|
+
let live = true
|
|
333
|
+
Promise.resolve()
|
|
334
|
+
.then(() => read())
|
|
335
|
+
.then(view => {
|
|
336
|
+
if (live) setWallet(walletOf(view))
|
|
337
|
+
}, () => {
|
|
338
|
+
if (live) setWallet(undefined)
|
|
339
|
+
})
|
|
340
|
+
return () => {
|
|
341
|
+
live = false
|
|
342
|
+
}
|
|
343
|
+
}, [enabled, read])
|
|
344
|
+
if (!enabled) return null
|
|
345
|
+
// The wrapper stays in the DOM even when there is no number to show, so a
|
|
346
|
+
// reader (and the verification script) can tell "registered but empty" from
|
|
347
|
+
// "not registered at all". An empty flex line costs the layout nothing.
|
|
348
|
+
if (wallet === undefined) {
|
|
349
|
+
return React.createElement('div', { 'data-polish-balance': 'empty', style: { display: 'none' } })
|
|
350
|
+
}
|
|
351
|
+
const money = formatBalance(wallet)
|
|
352
|
+
return React.createElement('div', {
|
|
353
|
+
title: 'Remaining account balance',
|
|
354
|
+
style: {
|
|
355
|
+
display: 'flex',
|
|
356
|
+
alignItems: 'center',
|
|
357
|
+
gap: '6px',
|
|
358
|
+
padding: '2px 8px 4px',
|
|
359
|
+
fontSize: '12px',
|
|
360
|
+
lineHeight: '16px',
|
|
361
|
+
minWidth: 0,
|
|
362
|
+
overflow: 'hidden',
|
|
363
|
+
whiteSpace: 'nowrap',
|
|
364
|
+
textOverflow: 'ellipsis',
|
|
365
|
+
opacity: 0.75,
|
|
366
|
+
},
|
|
367
|
+
}, props.wide === false ? money : 'Balance ' + money)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
inject: ['slots'],
|
|
372
|
+
/**
|
|
373
|
+
* Register this bundle's surfaces.
|
|
374
|
+
* @param ctx - the browser plugin context.
|
|
375
|
+
*/
|
|
376
|
+
apply(ctx) {
|
|
377
|
+
const configForms = () => {
|
|
378
|
+
try {
|
|
379
|
+
return ctx.get('configForms')
|
|
380
|
+
} catch {
|
|
381
|
+
return undefined
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
385
|
+
name: 'plugins.row.config',
|
|
386
|
+
key: ROW_KEY,
|
|
387
|
+
inject: () => ({ configForms }),
|
|
388
|
+
}, PolishConfig))
|
|
389
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
390
|
+
name: 'conversation.composer.dock',
|
|
391
|
+
id: 'polish-cost',
|
|
392
|
+
order: 2,
|
|
393
|
+
inject: () => ({ configForms }),
|
|
394
|
+
}, CostPill))
|
|
395
|
+
const loadBalance = makeBalanceReader(ctx, CLIENT_VERSION)
|
|
396
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
397
|
+
name: 'sidebar.footer.action',
|
|
398
|
+
id: 'polish-balance',
|
|
399
|
+
order: 3,
|
|
400
|
+
inject: () => ({ configForms, loadBalance }),
|
|
401
|
+
}, BalanceLine))
|
|
402
|
+
},
|
|
403
|
+
}
|
package/client/money.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two pure decisions behind the balance line: which wallet to show, and how
|
|
3
|
+
* to render it.
|
|
4
|
+
*
|
|
5
|
+
* They live in their own file because the browser face bundle is only ever run
|
|
6
|
+
* inside the Shell, where nothing can be imported and asserted — so the build
|
|
7
|
+
* inlines this module into `lib/client.js` (with the `export` keywords dropped)
|
|
8
|
+
* while `test/money.spec.mjs` imports it directly. What the test covers is
|
|
9
|
+
* therefore the same source the browser runs, not a copy of it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pick the wallet a balance line should show.
|
|
14
|
+
* @param view - the balance view the account Remote returned.
|
|
15
|
+
* @returns the first wallet carrying a balance, or undefined when the view has none.
|
|
16
|
+
*/
|
|
17
|
+
export function walletOf(view) {
|
|
18
|
+
if (view === undefined || view === null || view.status !== 'ready' || !Array.isArray(view.value)) return undefined
|
|
19
|
+
return view.value.find(wallet => wallet !== null && typeof wallet === 'object' && typeof wallet.balance === 'string')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format one wallet as money.
|
|
24
|
+
* @param wallet - a wallet with `currency` and `balance`.
|
|
25
|
+
* @returns the amount with its currency symbol, at two decimals.
|
|
26
|
+
*/
|
|
27
|
+
export function formatBalance(wallet) {
|
|
28
|
+
const symbol = wallet.currency === 'CNY' ? '\u00A5' : '$'
|
|
29
|
+
const amount = Number(wallet.balance)
|
|
30
|
+
return Number.isFinite(amount) ? symbol + amount.toFixed(2) : symbol + String(wallet.balance)
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"row": "polish",
|
|
3
|
+
"switches": [
|
|
4
|
+
{
|
|
5
|
+
"key": "sessionCost",
|
|
6
|
+
"label": "Estimated session cost",
|
|
7
|
+
"description": "Show what the open session has cost so far, priced from recorded usage at the published DeepSeek rates.",
|
|
8
|
+
"defaultOn": true
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"key": "accountBalance",
|
|
12
|
+
"label": "Account balance",
|
|
13
|
+
"description": "Show the remaining account balance in the sidebar foot, above the account row.",
|
|
14
|
+
"defaultOn": true
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# AryaCode Polish bundle.
|
|
2
|
+
#
|
|
3
|
+
# One row. The row is the install surface: mounting it mounts the host half of
|
|
4
|
+
# every feature, and it names the bare package specifier because the Shell
|
|
5
|
+
# classifies a client row by `exactPackageSpecifier` — a subpath such as
|
|
6
|
+
# `@aryacode/dsh-aryacode-polish/polish` is rejected and yields no client entry.
|
|
7
|
+
#
|
|
8
|
+
# The feature switches are the row's config, not separate rows:
|
|
9
|
+
#
|
|
10
|
+
# spellcheck → right-click spelling corrections in the composer (on)
|
|
11
|
+
#
|
|
12
|
+
# The patch only ever inserts. It must never target a row another bundle owns:
|
|
13
|
+
# a profile without that row makes composition fail ("patch: entry ... not
|
|
14
|
+
# found"), which is why the patch is kept to its own one row.
|
|
15
|
+
- insert:
|
|
16
|
+
- id: polish
|
|
17
|
+
name: '@aryacode/dsh-aryacode-polish'
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Parked: right-click spelling corrections
|
|
2
|
+
|
|
3
|
+
**Status: not implemented, and not implementable as a plugin.** This note exists
|
|
4
|
+
so the investigation is not repeated and so the fix can be taken up in one short
|
|
5
|
+
change to the shell when we choose to.
|
|
6
|
+
|
|
7
|
+
## What the user saw in AryaAI
|
|
8
|
+
|
|
9
|
+
Right-clicking a misspelled word in the composer listed candidate corrections
|
|
10
|
+
(`heel`, `hello`, `helot`, `heels`, `hele`) above the standard Undo/Redo/Cut/
|
|
11
|
+
Copy/Paste/Select All items.
|
|
12
|
+
|
|
13
|
+
## Why AryaCode does not show them
|
|
14
|
+
|
|
15
|
+
AryaAI built that menu in the Electron main process from Chromium's own
|
|
16
|
+
spellchecker output — `AryaAI/apps/desktop/src/main.ts:294-329`:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
window.webContents.on('context-menu', (_event, params) => {
|
|
20
|
+
const { isEditable, selectionText, editFlags } = params
|
|
21
|
+
const suggestions = params.dictionarySuggestions
|
|
22
|
+
const items: MenuItemConstructorOptions[] = []
|
|
23
|
+
for (const suggestion of suggestions) {
|
|
24
|
+
items.push({
|
|
25
|
+
label: suggestion,
|
|
26
|
+
click: () => { window.webContents.replaceMisspelling(suggestion) },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
if (suggestions.length > 0) items.push({ type: 'separator' })
|
|
30
|
+
// …then the six standard roles
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
AryaCode's `apps/desktop/src/main.ts:285-311` is the same handler with the
|
|
34
|
+
suggestion block removed. It destructures only
|
|
35
|
+
`{ isEditable, selectionText, editFlags }`, so `dictionarySuggestions` is never
|
|
36
|
+
read and `replaceMisspelling` is never called. The handler is identical on
|
|
37
|
+
`master` and `rebrand/identity`.
|
|
38
|
+
|
|
39
|
+
Spellcheck itself is still on: the main window's `webPreferences`
|
|
40
|
+
(`apps/desktop/src/main.ts:238-246`) sets no `spellcheck` key, so Electron's
|
|
41
|
+
default `true` stands — which is why the red squiggle still appears while the
|
|
42
|
+
menu offers no corrections. This is a desktop-shell regression, not a missing
|
|
43
|
+
capability. In a plain browser, Chromium's own menu still offers suggestions.
|
|
44
|
+
|
|
45
|
+
## Why a plugin cannot do it
|
|
46
|
+
|
|
47
|
+
Four independent blockers, each sufficient on its own:
|
|
48
|
+
|
|
49
|
+
1. **The menu is built in the main process.** A client face runs with
|
|
50
|
+
`contextIsolation: true, sandbox: true` and cannot reach `Menu`/`webContents`.
|
|
51
|
+
2. **Chromium's native menu takes no page-side entries** in a browser, and the
|
|
52
|
+
page cannot read the suggestions either.
|
|
53
|
+
3. **The documented client contextmenu pipeline excludes the composer** —
|
|
54
|
+
`packages/client/ui-chat/src/client/chat/text-interaction.ts:228` returns
|
|
55
|
+
early for `[contenteditable="true"]`, and only `[data-text-interaction-source]`
|
|
56
|
+
nodes (message bodies) dispatch at all.
|
|
57
|
+
4. **The Lexical editor handle is contractually out of reach** —
|
|
58
|
+
`packages/client/ui-conversation/src/client/contract/draft-editor.ts:64-71`:
|
|
59
|
+
"Handed to the composer-bar entry through its own inject — package-internal,
|
|
60
|
+
never across a plugin boundary."
|
|
61
|
+
|
|
62
|
+
There is no slot key for a context menu anywhere in the generated slot catalog
|
|
63
|
+
(`packages/extensions/cordis-client-runner/src/client/slot-catalog.ts`). The
|
|
64
|
+
nearest seat, `conversation.input.overlay` (declared
|
|
65
|
+
`packages/client/ui-conversation/src/client/contract/slots.ts:292`, anchored
|
|
66
|
+
`.../skeleton/InputBar.tsx:423`), renders floating UI inside the composer card but
|
|
67
|
+
supplies neither the right-click trigger nor the misspelled word.
|
|
68
|
+
|
|
69
|
+
## The change that would fix it
|
|
70
|
+
|
|
71
|
+
One edit in `apps/desktop/src/main.ts:285`, restoring AryaAI's behaviour:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags,
|
|
75
|
+
misspelledWord, dictionarySuggestions }) => {
|
|
76
|
+
const items: MenuItemConstructorOptions[] = []
|
|
77
|
+
if (misspelledWord !== '' && dictionarySuggestions.length > 0) {
|
|
78
|
+
for (const suggestion of dictionarySuggestions) {
|
|
79
|
+
items.push({ label: suggestion, click: () => window.webContents.replaceMisspelling(suggestion) })
|
|
80
|
+
}
|
|
81
|
+
items.push({ type: 'separator' })
|
|
82
|
+
}
|
|
83
|
+
// …the existing role items follow, unchanged
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`apps/desktop/tests/main-startup.spec.ts` already drives this handler by emitting
|
|
87
|
+
`context-menu` with fake params, so the test for the restored items is a few
|
|
88
|
+
lines beside the existing assertions.
|
|
89
|
+
|
|
90
|
+
Two consequences worth stating before doing it:
|
|
91
|
+
|
|
92
|
+
- It is a **shell change**, so it ships inside the next harness build and does
|
|
93
|
+
not need the market at all. It survives every later update because the updater
|
|
94
|
+
replaces the payload with a build from the same source.
|
|
95
|
+
- It cannot be turned off from a plugin switch. If it needs to be optional, that
|
|
96
|
+
belongs in the shell's own settings, not in this bundle.
|
|
97
|
+
|
|
98
|
+
## What a plugin-shaped workaround would cost
|
|
99
|
+
|
|
100
|
+
A host half could `require('electron')`, remove the shell's `context-menu`
|
|
101
|
+
listener for each window and re-implement the whole menu with suggestions. It
|
|
102
|
+
would work on the desktop today and need no shell edit, but it deletes a listener
|
|
103
|
+
the shell owns, duplicates its localized labels, and breaks the moment that
|
|
104
|
+
handler changes — the code updates touch most often. It was offered and
|
|
105
|
+
deliberately not taken.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve one value from a plugin row's config.
|
|
3
|
+
*
|
|
4
|
+
* A field marked `.volatile()` in the `Config` schema arrives as a live
|
|
5
|
+
* reference ({ get() }) rather than as the value itself: schemastery wraps it
|
|
6
|
+
* with `createVolatile`, so `config.spellcheck` is the reference, not `true`.
|
|
7
|
+
* Reading the value directly made every switch look off to `apply()` — the row
|
|
8
|
+
* switched its feature off however the settings page was set.
|
|
9
|
+
*
|
|
10
|
+
* Keep this tolerance in one place so every feature resolves its switch the same
|
|
11
|
+
* way, and so a plain value (tests, older hosts) still works.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-aryacode-polish-config
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read a config field that may be a volatile reference.
|
|
18
|
+
*
|
|
19
|
+
* @param input - the raw field, a reference, or undefined.
|
|
20
|
+
* @param fallback - value to use when the field is absent.
|
|
21
|
+
* @returns the current value, or the fallback.
|
|
22
|
+
*/
|
|
23
|
+
export function resolve(input, fallback) {
|
|
24
|
+
const current = input && typeof input.get === 'function' ? input.get() : input
|
|
25
|
+
return current === undefined ? fallback : current
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a boolean switch.
|
|
30
|
+
*
|
|
31
|
+
* @param input - the raw field, a reference, or undefined.
|
|
32
|
+
* @param fallback - value to use when the field is absent or not a boolean.
|
|
33
|
+
* @returns the boolean value.
|
|
34
|
+
*/
|
|
35
|
+
export function flag(input, fallback = false) {
|
|
36
|
+
const current = resolve(input, fallback)
|
|
37
|
+
return typeof current === 'boolean' ? current : fallback
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a string-list field.
|
|
42
|
+
*
|
|
43
|
+
* @param input - the raw field, a reference, or undefined.
|
|
44
|
+
* @returns the list, or an empty list.
|
|
45
|
+
*/
|
|
46
|
+
export function list(input) {
|
|
47
|
+
const current = resolve(input, [])
|
|
48
|
+
return Array.isArray(current) ? current : []
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read a string field.
|
|
53
|
+
*
|
|
54
|
+
* @param input - the raw field, a reference, or undefined.
|
|
55
|
+
* @param fallback - value to use when the field is absent or not a string.
|
|
56
|
+
* @returns the string value.
|
|
57
|
+
*/
|
|
58
|
+
export function text(input, fallback = '') {
|
|
59
|
+
const current = resolve(input, fallback)
|
|
60
|
+
return typeof current === 'string' ? current : fallback
|
|
61
|
+
}
|