@aryacode/dsh-aryacode-polish 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ ## [0.2.4] - 2026-10-27
4
+
5
+ ### Changed
6
+
7
+ - **The card leads with the number it is about.** It now opens with the
8
+ "Estimated session cost" heading and the live `≈$…` amount, then a stacked bar
9
+ showing how the session's tokens split between cached input, uncached input,
10
+ cache writes and output, then the rows: cache hit, tokens, model, provider,
11
+ requests, and the estimate again above the footnote. Model and provider are
12
+ always named (they were only listed when a session had more than one), so a
13
+ first look says which route the money was spent on.
14
+
15
+ ## [0.2.3] - 2026-10-27
16
+
17
+ ### Fixed
18
+
19
+ - **A sub-cent turn no longer reads `≈$0`.** `formatMoney` rounded to three
20
+ decimals, so a real 22.1K-token turn whose actual cost was about $0.000095
21
+ showed as zero. Amounts under a cent now keep two significant digits
22
+ (`≈$0.000095`), cents keep three decimals, and dollars keep two.
23
+
24
+ ## [0.2.2] - 2026-10-27
25
+
26
+ ### Fixed
27
+
28
+ - The pill's `≈` was stored as cp1252 mojibake (`≈`), so a real turn rendered
29
+ `≈$0.003` instead of `≈$0.003`. Same cause as the two strings repaired in
30
+ 0.2.1 — a PowerShell round-trip over the face source — and the last of that
31
+ damage. Verified against a real turn in Dev AryaCode.
32
+
33
+ ## [0.2.1] - 2026-10-27
34
+
35
+ ### Fixed
36
+
37
+ - **The Configure page found no form.** The page body asked
38
+ `configForms.get('<package>#<row>')`, but the Plugins page builds a row's form
39
+ from the **row id** alone (`PluginManagerPage.tsx:1735` passes
40
+ `form={formFor(openRow.rowId)}`, and `formFor` matches `view.ns === id`). The
41
+ lookup now asks for `polish`, so the row's page reads the live form instead of
42
+ saying the row has no configuration to change. The `plugins.row.config`
43
+ registration key is unchanged: that one is still `<package>#<row>`.
44
+ - Two strings that a PowerShell round-trip had written as cp1252 mojibake
45
+ (`Loading configuration…`, `Saving…`, and an em dash) are back to UTF-8.
46
+
47
+ ## [0.2.0] - 2026-10-27
48
+
49
+ ### Added
50
+
51
+ - **Estimated session cost.** A composer-dock pill (`≈$0.39`) that opens a
52
+ breakdown card: cache hit, uncached input, cached input, output, requests, and
53
+ the estimate itself, with the same "your provider's bill is authoritative"
54
+ footnote. The money is computed **host-side**: `entries/usage.js` folds
55
+ model-attributed usage out of the durable session log, and `entries/cost.js`
56
+ registers a `polishCost` projection whose wire view is already priced at the
57
+ published DeepSeek rates (off-peak table, peak at 2×, cache writes billed as
58
+ misses). The browser half only formats numbers, so no rate table ships to the
59
+ client and an unknown model or provider leaves the estimate marked incomplete
60
+ instead of quietly reporting a wrong number.
61
+ - **`test/cost.spec.mjs`.** Fold and pricing coverage: peak/off-peak, cache
62
+ rates, cache writes as misses, unknown route handling, retry accounting, and
63
+ the empty-session wire view.
64
+
65
+ ## [0.1.1] - 2026-10-27
66
+
67
+ ### Added
68
+
69
+ - **The row's Configure page.** `lib/client.js` is the bundle's browser face: it
70
+ registers the `polish` row's configuration page, so the row now has a page of
71
+ its own that reads the row's live config form and writes it back with the
72
+ Host's revision. With no feature landed yet the page states that plainly
73
+ instead of showing a switch that toggles nothing.
74
+ - **`client/switches.json`.** The browser half of the feature seam: one entry per
75
+ switch, and `test/client-face.spec.mjs` fails when it and `entries/registry.js`
76
+ disagree in either direction.
77
+
78
+ ## [0.1.0] - 2026-10-27
79
+
80
+ ### Added
81
+
82
+ - **AryaCode Polish bundle.** One package, one row (`polish`), one switch per
83
+ quality-of-life feature, so each refinement can be turned off from its
84
+ Configure page without uninstalling the bundle.
85
+ - **Feature seam.** `entries/registry.js` is the single list of features, and the
86
+ row's config schema is derived from it: a new entry gains its volatile switch
87
+ with no second registration, and switching a feature off disposes its host
88
+ half instead of merely hiding it.
89
+ - **`docs/parked-spellcheck.md`.** Records why right-click spelling corrections
90
+ cannot be a plugin, and the exact shell change that would restore them.
91
+
92
+ ### Not shipped
93
+
94
+ - Right-click spelling corrections. The candidates came from Chromium's
95
+ spellchecker through the Electron main process; the shell no longer reads
96
+ them, and no plugin contract can reach that menu. Parked with evidence.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KonnectSuite
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,135 @@
1
- # Temporary Holding Version
1
+ # AryaCode Polish
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One install for the small things that make AryaCode feel finished.
4
+
5
+ AryaCode Polish is a plugin **bundle**: it adds surfaces to the app it is
6
+ installed into and patches nothing. It is one package, one Plugins row, and one
7
+ switch per feature, so a refinement that turns out to be wrong for a machine can
8
+ be turned off without uninstalling anything.
9
+
10
+ Status: **0.2.0.** One feature shipped: the estimated session cost pill.
11
+
12
+ ## What it is
13
+
14
+ - **One row.** The bundle inserts a single row, id `polish`, into the profile's
15
+ composition (`cordis.patch.yml`). That row is the install surface: it mounts
16
+ the host half of every enabled feature and names the bare package specifier so
17
+ the Shell can find the browser face.
18
+ - **One switch per feature.** Every feature is gated by its own boolean in the
19
+ row's Configure page, on by default. Turning it off disposes that feature only.
20
+ - **Additive.** No fork of the shell, no edits to `app.asar`, no patched
21
+ installed app, no DOM hacks against private class names.
22
+
23
+ ## Switches
24
+
25
+ Row: **AryaCode Polish** (`polish`).
26
+
27
+ Each entry in `entries/registry.js` becomes one volatile boolean in this
28
+ Configure page automatically.
29
+
30
+ | Switch | Default | What it does |
31
+ | --- | --- | --- |
32
+ | **Estimated session cost** (`sessionCost`) | On | A `≈$0.39` pill in the composer dock that opens a breakdown card — cache hit, uncached input, cached input, output, requests, and the estimate — priced from the session's recorded usage at the published DeepSeek rates. |
33
+
34
+ ### How the cost is computed
35
+
36
+ The numbers come from the Host, never from the browser:
37
+
38
+ - `entries/usage.js` folds model-attributed provider usage out of the durable
39
+ session log, one row per provider, model, and hour, and tags each band as peak
40
+ or off-peak (Beijing weekdays 09–12 and 14–18 are billed at 2×).
41
+ - `entries/cost.js` registers the `polishCost` projection and prices that fold:
42
+ cache reads at the cache rate, uncached input and cache writes at the miss
43
+ rate, output at the output rate. The wire view is already money, so no rate
44
+ table reaches the client.
45
+ - An unknown provider, model, or rate period keeps its tokens and marks the
46
+ estimate **incomplete** — the pill says so rather than reporting a number it
47
+ cannot stand behind.
48
+ - The rate table is dated and lives in one place. When DeepSeek changes prices,
49
+ append a period; do not edit history.
50
+
51
+ ## Declaring a feature
52
+
53
+ `entries/registry.js` is the single list of features. One entry is one feature:
54
+
55
+ ```js
56
+ {
57
+ id: 'spellcheck', // becomes the config field and the switch
58
+ label: 'Spelling corrections',
59
+ description: 'Right-click a misspelled word to pick a correction.',
60
+ defaultOn: true,
61
+ mount: spellcheck, // optional host half: a Cordis plugin
62
+ }
63
+ ```
64
+
65
+ The row's `Config` schema is derived from that list, so a new entry gains its
66
+ volatile switch with no second registration. `mount` is plugged in when the
67
+ switch is on and disposed when it is off, so a disabled feature stops costing the
68
+ app rather than merely hiding. A feature that only draws UI omits `mount` and
69
+ contributes a client face instead.
70
+
71
+ ## Parked
72
+
73
+ **Right-click spelling corrections** — investigated and not shipped. The
74
+ candidates you saw in AryaAI came from Chromium's spellchecker through the
75
+ Electron main process, which the AryaCode shell no longer reads; a plugin cannot
76
+ reach it. The finding, the exact 10-line shell change that would restore it, and
77
+ why the plugin alternatives were rejected are in
78
+ [`docs/parked-spellcheck.md`](docs/parked-spellcheck.md).
79
+
80
+ ## Install
81
+
82
+ From the AryaCode Plugins market: open the market, find **AryaCode Polish**,
83
+ install, then quit and reopen AryaCode — the market registry is cached in the
84
+ running process.
85
+
86
+ Manual, for development only:
87
+
88
+ ```bash
89
+ node --import tsx/esm apps/cli/src/bin.ts plugin --profile web add <path-to-tgz>
90
+ ```
91
+
92
+ ## Requirements
93
+
94
+ - AryaCode with the Cordis plugin API (`@deepseek-ai/cordis ~4.0.4`).
95
+ - Node `^22.19.0 || >=24.0.0` to build from source.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ pnpm install
101
+ pnpm test # patch shape, config accessors, feature seam, docs parity
102
+ pnpm pack # produces aryacode-dsh-aryacode-polish-<version>.tgz
103
+ ```
104
+
105
+ Test on **Dev AryaCode** (`%LOCALAPPDATA%\AryaCode-dev\home`), never on the
106
+ installed app.
107
+
108
+ ## Layout
109
+
110
+ ```
111
+ cordis.patch.yml one insert, one row
112
+ entries/polish.js the row: Config derived from the registry, apply()
113
+ entries/registry.js the feature list — the one place a feature is declared
114
+ entries/usage.js the fold: model-attributed usage out of the session log
115
+ entries/cost.js the polishCost projection: rates, pricing, host mount
116
+ entries/features/ one module per feature
117
+ entries/config.js volatile-reference accessors (resolve/flag/list/text)
118
+ lib/index.js mount point re-exporting the row
119
+ client/face.js browser face source: the Configure page and every surface
120
+ client/switches.json the browser half of the seam — one entry per switch
121
+ scripts/build-client.mjs wraps client/face.js into lib/client.js
122
+ lib/client.js generated: the bundle the Shell loads
123
+ locale/ card title and description (en, zh)
124
+ docs/ parked investigations
125
+ test/ node --test specs
126
+ ```
127
+
128
+ The Configure page is generated, not hand-maintained: `lib/client.js` is built
129
+ from `client/face.js` and `client/switches.json` by `pnpm build:client` (which
130
+ `pnpm pack` runs), and `test/client-face.spec.mjs` fails when `switches.json` and
131
+ `entries/registry.js` disagree in either direction.
132
+
133
+ ## Licence
134
+
135
+ MIT © KonnectSuite
package/client/face.js ADDED
@@ -0,0 +1,342 @@
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
+ return {
317
+ inject: ['slots'],
318
+ /**
319
+ * Register this bundle's surfaces.
320
+ * @param ctx - the browser plugin context.
321
+ */
322
+ apply(ctx) {
323
+ const configForms = () => {
324
+ try {
325
+ return ctx.get('configForms')
326
+ } catch {
327
+ return undefined
328
+ }
329
+ }
330
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
331
+ name: 'plugins.row.config',
332
+ key: ROW_KEY,
333
+ inject: () => ({ configForms }),
334
+ }, PolishConfig))
335
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
336
+ name: 'conversation.composer.dock',
337
+ id: 'polish-cost',
338
+ order: 2,
339
+ inject: () => ({ configForms }),
340
+ }, CostPill))
341
+ },
342
+ }
@@ -0,0 +1,11 @@
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
+ }
@@ -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'