@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/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
+ }
@@ -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
+ }
@@ -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
+ }