@linxin666/dsh-usage 0.4.10 → 0.4.12

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.
@@ -0,0 +1,510 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Form model behind the plugin settings card. Every control applies itself:
4
+ * a pick (a segment, a select option, a reset) writes the moment it is made,
5
+ * and typed text writes once typing pauses or the reader leaves the field.
6
+ * Between the edit and the write the edit is held here as a draft, so what is
7
+ * on screen is exactly what the write will store; a write the Host refuses
8
+ * puts a pick back to the stored value and keeps typed text for correcting.
9
+ * Family-shared slice inlined into each plugin's client bundle.
10
+ */
11
+
12
+ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
13
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
14
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
15
+
16
+ /** The write one field's draft text performs when it is applied. */
17
+ export type FieldWrite =
18
+ | { kind: 'set'; value: unknown }
19
+ | { kind: 'clear' }
20
+
21
+ /** How one field converts between its stored value and its draft text. */
22
+ export interface FieldSpec {
23
+ /** Field name inside the namespace section. */
24
+ field: string
25
+ /**
26
+ * How the reader enters a value, which decides when it is written: a `pick`
27
+ * is a whole value at once and applies immediately; `type` is text arriving
28
+ * a keystroke at a time and applies once typing pauses or the field is left.
29
+ */
30
+ entry: 'pick' | 'type'
31
+ /**
32
+ * Whether the Host treats this field as a secret and redacts its value from
33
+ * the read-back (role('secret') in the section schema). Redacted secrets are
34
+ * never compared against the draft on save: the Host strips them from every
35
+ * wire view layer, so the settled snapshot carries nothing to read back. A
36
+ * staged secret set is judged by the mutation settling; the rest of its
37
+ * batch, when one exists, is still judged by read-back, and the atomic
38
+ * mutation lands every write or none.
39
+ */
40
+ secret?: boolean
41
+ /** Render a stored value as draft text; the empty string when the section carries none. */
42
+ format: (value: unknown) => string
43
+ /**
44
+ * The write this draft text performs, or undefined when the text is not a
45
+ * value this field accepts — which holds the draft unwritten rather than
46
+ * discarding it.
47
+ */
48
+ parse: (text: string) => FieldWrite | undefined
49
+ }
50
+
51
+ /** One field as the card renders it. */
52
+ export interface FieldState {
53
+ /** Draft text the control renders. */
54
+ text: string
55
+ /** Whether the field leaves (or will leave) a user-layer entry. */
56
+ overridden: boolean
57
+ /** Whether the draft is not a value this field accepts, which holds it unwritten. */
58
+ invalid: boolean
59
+ }
60
+
61
+ /** Form state every plugin settings card shares. */
62
+ export interface CardShell {
63
+ /** False while the namespace is still loading; the card renders nothing. */
64
+ available: boolean
65
+ /**
66
+ * Whether the namespace is actually served to this client. False when the
67
+ * Host deployment does not expose it (e.g. the owning plugin's settings
68
+ * domain is not mounted): the card renders an explanation
69
+ * instead of its form, so a missing namespace never looks like a missing
70
+ * plugin.
71
+ */
72
+ exposed: boolean
73
+ /** Whether the Host document accepts writes. */
74
+ writable: boolean
75
+ /** Whether the form holds drafts not yet written (typed text waiting for its pause). */
76
+ dirty: boolean
77
+ /** Whether any draft is invalid, which holds it unwritten. */
78
+ invalid: boolean
79
+ /** Whether a write is crossing the wire. */
80
+ saving: boolean
81
+ /** Whether the last write did not land; cleared by the next edit or write. */
82
+ failed: boolean
83
+ /**
84
+ * The rejection code/message the Host returned for the last failed save,
85
+ * surfaced next to the generic failure text. Undefined while no save has
86
+ * failed (or the failure carried no server reason).
87
+ */
88
+ failedReason?: string
89
+ }
90
+
91
+ /** The write actions the card's slot entry injects. */
92
+ export interface CardActions {
93
+ /** Take draft text for one field; a pick writes at once, typed text after a pause. */
94
+ edit: (field: string, text: string) => void
95
+ /** Clear one field at once, so it re-inherits the composition layer. */
96
+ resetField: (field: string) => void
97
+ /** Write every pending draft now (a text field's Enter or blur), then re-seed from what the Host accepted. */
98
+ save: () => void
99
+ /** Drop every pending draft, putting each control back to the stored value (a text field's Escape). */
100
+ discard: () => void
101
+ }
102
+
103
+ /** How long typed text rests before it is written. */
104
+ export const TYPE_PAUSE_MS = 600
105
+
106
+ /** One field's staged edit. */
107
+ interface StagedEdit {
108
+ /** Draft text the control renders. */
109
+ text: string
110
+ /** True when this edit clears the field whatever text it shows. */
111
+ clear: boolean
112
+ }
113
+
114
+ /** One staged edit resolved into the write a save performs. */
115
+ interface PlannedWrite {
116
+ /** Field this entry writes. */
117
+ field: string
118
+ /** The durable write this entry performs, inside the save's one atomic mutation. */
119
+ op: BatchedWrite
120
+ /**
121
+ * Read the settled snapshot back and report whether the Host holds this
122
+ * write's effect. Undefined when the draft is not a value the field
123
+ * accepts: there is nothing to write, and the entry blocks the save.
124
+ */
125
+ judge: (() => boolean) | undefined
126
+ }
127
+
128
+ /** One durable write inside the save's atomic form mutation. */
129
+ export interface BatchedWrite {
130
+ /** Field this entry writes. */
131
+ field: string
132
+ /** set stores a value; unset drops the leaf. */
133
+ op: 'set' | 'unset'
134
+ /** Value for op set (absent for unset). */
135
+ value?: unknown
136
+ }
137
+
138
+ /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
139
+ export interface NumberConstraints {
140
+ /** The accepted value must be a whole number. */
141
+ integer?: boolean
142
+ /** The accepted value must be at least this. */
143
+ min?: number
144
+ }
145
+
146
+ /** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints stays unwritten. */
147
+ export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
148
+ const { integer = false, min } = constraints
149
+ return {
150
+ field,
151
+ entry: 'type',
152
+ format: value => typeof value === 'number' ? String(value) : '',
153
+ parse: (text) => {
154
+ const trimmed = text.trim()
155
+ if (trimmed === '') return { kind: 'clear' }
156
+ const parsed = Number(trimmed)
157
+ if (!Number.isFinite(parsed)) return undefined
158
+ if (integer && !Number.isInteger(parsed)) return undefined
159
+ if (min !== undefined && parsed < min) return undefined
160
+ return { kind: 'set', value: parsed }
161
+ },
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A free-text field. An empty draft clears the field. `entry` is `pick` when
167
+ * the text arrives whole from a picker (a preset or a reasoning level).
168
+ */
169
+ export function textField(field: string, entry: FieldSpec['entry'] = 'type'): FieldSpec {
170
+ return {
171
+ field,
172
+ entry,
173
+ format: value => typeof value === 'string' ? value : '',
174
+ parse: (text) => {
175
+ const trimmed = text.trim()
176
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
177
+ },
178
+ }
179
+ }
180
+
181
+ /**
182
+ * A free-text field the Host treats as a secret and redacts from the read-back
183
+ * (role('secret') in the section schema). The card still edits it like text,
184
+ * but a save never compares the redacted value back: the staged set is judged
185
+ * by the mutation settling (see {@link FieldSpec.secret}).
186
+ */
187
+ export function secretField(field: string): FieldSpec {
188
+ return { ...textField(field), secret: true }
189
+ }
190
+
191
+ /** A boolean field, edited through true/false draft text. */
192
+ export function booleanField(field: string): FieldSpec {
193
+ return {
194
+ field,
195
+ entry: 'pick',
196
+ format: value => typeof value === 'boolean' ? String(value) : '',
197
+ parse: (text) => {
198
+ const trimmed = text.trim()
199
+ if (trimmed === '') return { kind: 'clear' }
200
+ if (trimmed === 'true') return { kind: 'set', value: true }
201
+ if (trimmed === 'false') return { kind: 'set', value: false }
202
+ return undefined
203
+ },
204
+ }
205
+ }
206
+
207
+ /** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
208
+ export function choiceField(field: string, choices: readonly string[]): FieldSpec {
209
+ return {
210
+ field,
211
+ entry: 'pick',
212
+ format: value => typeof value === 'string' && choices.includes(value) ? value : '',
213
+ parse: (text) => {
214
+ if (text === '') return { kind: 'clear' }
215
+ return choices.includes(text) ? { kind: 'set', value: text } : undefined
216
+ },
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Applies one card's edits to one settings namespace as the reader makes them.
222
+ *
223
+ * The Host is the only authority on whether a value was accepted — its
224
+ * validators own the constraints no schema can express — so the outcome is
225
+ * read back from the section rather than predicted here. A write that did not
226
+ * land puts each pick back to the stored value, the way a switch that could not
227
+ * flip springs back, and keeps typed text so the reader can correct it instead
228
+ * of retyping.
229
+ */
230
+ export class CardForm<T> {
231
+ private readonly specs: Map<string, FieldSpec>
232
+ private readonly staged = new Map<string, StagedEdit>()
233
+ private readonly listeners = new Set<() => void>()
234
+ /** The form subscription installed in the constructor; released by dispose(). */
235
+ private readonly disposeForm: () => void
236
+ private disposed = false
237
+ /** The pending write of typed text, waiting for typing to pause. */
238
+ private typePause: ReturnType<typeof setTimeout> | undefined
239
+ private saving = false
240
+ /** A save asked for while one was in flight; it runs once the first settles (#1754). */
241
+ private saveQueued = false
242
+ private failed = false
243
+ private failedReason: string | undefined
244
+
245
+ /** @param scope - the bound configuration form for this card's namespace. */
246
+ constructor(
247
+ private readonly scope: ConfigForm<T>,
248
+ specs: FieldSpec[],
249
+ ) {
250
+ this.specs = new Map(specs.map(spec => [spec.field, spec]))
251
+ this.disposeForm = scope.subscribe(() => { this.publish() })
252
+ }
253
+
254
+ /**
255
+ * Release the form subscription and every bound store listener. The card
256
+ * must call this on teardown; later calls are no-ops.
257
+ */
258
+ dispose(): void {
259
+ if (this.disposed) return
260
+ this.disposed = true
261
+ clearTimeout(this.typePause)
262
+ this.disposeForm()
263
+ this.listeners.clear()
264
+ }
265
+
266
+ /** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
267
+ bind<S>(project: () => S): SnapshotStore<S> {
268
+ const store = createSnapshotStore(project())
269
+ this.listeners.add(() => { store.set(project()) })
270
+ return store
271
+ }
272
+
273
+ /** Read the card-level state: what the Host serves, and what is still waiting to be written. */
274
+ shell(): CardShell {
275
+ const snapshot = this.scope.getSnapshot()
276
+ const plan = this.plan()
277
+ return {
278
+ available: snapshot.status !== 'loading',
279
+ exposed: snapshot.status === 'ready',
280
+ writable: snapshot.writable,
281
+ dirty: plan.length > 0,
282
+ invalid: plan.some(item => item.judge === undefined),
283
+ saving: this.saving,
284
+ failed: this.failed,
285
+ ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
286
+ }
287
+ }
288
+
289
+ /** Read one field's state from the effective section and its staged draft. */
290
+ field(field: string): FieldState {
291
+ const spec = this.specOf(field)
292
+ const staged = this.staged.get(field)
293
+ if (staged === undefined) {
294
+ return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
295
+ }
296
+ const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
297
+ return {
298
+ text: staged.text,
299
+ overridden: write?.kind === 'set',
300
+ invalid: write === undefined,
301
+ }
302
+ }
303
+
304
+ /** The actions the card's slot registration injects. */
305
+ actions(): CardActions {
306
+ return {
307
+ edit: (field, text) => { this.stage(field, { text, clear: false }) },
308
+ resetField: (field) => {
309
+ this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
310
+ },
311
+ save: () => { this.applyNow() },
312
+ discard: () => {
313
+ clearTimeout(this.typePause)
314
+ if (this.staged.size === 0 && !this.failed) return
315
+ this.staged.clear()
316
+ this.failed = false
317
+ this.failedReason = undefined
318
+ this.publish()
319
+ },
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Run one write, and re-run it once if another was asked for while this one
325
+ * was still in flight.
326
+ *
327
+ * A write is a Host round trip that also drives the profile reconcile, and
328
+ * the Host runs it inside one exclusive transaction. Answering an edit that
329
+ * arrives mid-flight by returning immediately dropped it with no explanation
330
+ * (#1754). Serializing instead means an edit made while another write is
331
+ * still settling is written against the settled state.
332
+ * @returns settlement after the mutation and the read-back.
333
+ */
334
+ async requestSave(): Promise<void> {
335
+ if (this.saving) {
336
+ this.saveQueued = true
337
+ return
338
+ }
339
+ await this.save()
340
+ if (!this.saveQueued) return
341
+ this.saveQueued = false
342
+ await this.requestSave()
343
+ }
344
+
345
+ /**
346
+ * Write every valid draft in one atomic form mutation, then re-seed from
347
+ * what the Host accepted. A draft the field does not accept stays on screen,
348
+ * unwritten, without holding the others back.
349
+ *
350
+ * The batch rides one mutate, so cross-field validate hooks (baseURL+model)
351
+ * judge it as a unit: the Host either applies every write or refuses the
352
+ * batch. The form contract answers a refusal or a skipped write with `false`
353
+ * (it recovers with a fresh Host view instead of throwing), so the outcome is
354
+ * judged twice: the answer itself, and then the settled snapshot read back
355
+ * one planned write at a time. One missed write fails the whole batch. A
356
+ * transport that rejects instead (the dsh-web bridge controller on a dead
357
+ * connection) reports through the same failure path with its rejection
358
+ * message. A batch that did not land puts its picks and clears back to the
359
+ * stored value and keeps its typed text, so the reader can correct it.
360
+ * @returns settlement after the mutation and the read-back.
361
+ */
362
+ async save(): Promise<void> {
363
+ const plan = this.plan()
364
+ const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
365
+ if (valid.length === 0 || this.saving) return
366
+ // Snapshot the staged entries this write carries, so an edit staged while
367
+ // it is in flight (which replaces the same key) survives: only settle the
368
+ // key when the entry is still the one this write started from.
369
+ const pending = new Map<string, StagedEdit | undefined>()
370
+ for (const item of valid) pending.set(item.field, this.staged.get(item.field))
371
+ this.saving = true
372
+ this.failed = false
373
+ this.failedReason = undefined
374
+ this.publish()
375
+ // One atomic namespace mutation: the form contract takes ordered path
376
+ // operations, so the whole staged batch is validated, persisted, and
377
+ // recovered together — either every write lands or none does.
378
+ const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
379
+ ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
380
+ : { op: 'unset', path: [item.field] })
381
+ let failedReason: string | undefined
382
+ let accepted = false
383
+ try {
384
+ accepted = await this.scope.mutate(ops)
385
+ } catch (error) {
386
+ failedReason = error instanceof Error ? error.message : String(error)
387
+ }
388
+ // `false` is the contract's refusal/skip answer, and the form still
389
+ // resolves after a refusal (it recovers with a fresh view), so the answer
390
+ // alone is not enough: judge every planned write against the settled
391
+ // snapshot as well. The mutation is atomic, so one missed write fails the
392
+ // whole save and keeps the drafts.
393
+ const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
394
+ for (const [field, before] of pending) {
395
+ if (this.staged.get(field) !== before) continue
396
+ if (landed || before === undefined || before.clear || this.specOf(field).entry === 'pick') this.staged.delete(field)
397
+ }
398
+ this.saving = false
399
+ this.failed = !landed
400
+ // A refused or unlanded batch carries no server reason: the card surfaces
401
+ // its generic failure copy; a rejecting transport (the bridge) adds its
402
+ // message.
403
+ this.failedReason = failedReason
404
+ this.publish()
405
+ }
406
+
407
+ /**
408
+ * Every staged edit a write would carry. An entry whose draft is not a value
409
+ * its field accepts carries no write: the form is still dirty, and the draft
410
+ * stays on screen rather than being dropped. A staged edit that matches the
411
+ * effective section is not a write at all.
412
+ * @returns the planned writes, in the order the fields were staged.
413
+ */
414
+ private plan(): PlannedWrite[] {
415
+ const plan: PlannedWrite[] = []
416
+ for (const [field, staged] of this.staged) {
417
+ const spec = this.specOf(field)
418
+ if (staged.clear) {
419
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
420
+ continue
421
+ }
422
+ if (staged.text === spec.format(this.sectionValue(field))) continue
423
+ const write = spec.parse(staged.text)
424
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
425
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
426
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
427
+ }
428
+ return plan
429
+ }
430
+
431
+ /**
432
+ * Read-back judgment for a planned set: the user layer must hold the
433
+ * intended value once the mutation has settled.
434
+ */
435
+ private landedSet(field: string, value: unknown): boolean {
436
+ // A redacted secret never appears in any wire view layer: the Host strips
437
+ // role('secret') fields and reports them through a sidecar the form
438
+ // snapshot does not expose, so there is nothing to compare the draft
439
+ // against. Settling is the only signal the form has; the rest of the
440
+ // batch, when one exists, still carries the atomic verdict by read-back.
441
+ if (this.specOf(field).secret) return true
442
+ return this.userLayer()?.[field] === value
443
+ }
444
+
445
+ /**
446
+ * Read-back judgment for a planned unset: the field must be gone from the
447
+ * user layer once the mutation has settled.
448
+ */
449
+ private landedUnset(field: string): boolean {
450
+ return !this.stored(field)
451
+ }
452
+
453
+ /**
454
+ * Take one edit and schedule its write: a pick or a clear is written now,
455
+ * typed text once typing has rested for TYPE_PAUSE_MS.
456
+ */
457
+ private stage(field: string, edit: StagedEdit): void {
458
+ this.staged.set(field, edit)
459
+ this.failed = false
460
+ this.failedReason = undefined
461
+ this.publish()
462
+ if (edit.clear || this.specOf(field).entry === 'pick') {
463
+ this.applyNow()
464
+ return
465
+ }
466
+ clearTimeout(this.typePause)
467
+ this.typePause = setTimeout(() => { this.applyNow() }, TYPE_PAUSE_MS)
468
+ }
469
+
470
+ /** Write every pending draft now, taking the place of a waiting pause. */
471
+ private applyNow(): void {
472
+ clearTimeout(this.typePause)
473
+ this.typePause = undefined
474
+ if (this.disposed) return
475
+ void this.requestSave()
476
+ }
477
+
478
+ private specOf(field: string): FieldSpec {
479
+ const spec = this.specs.get(field)
480
+ // Every call site names a field this card declared; a missing one is a
481
+ // wiring mistake that must not degrade into a silently inert control.
482
+ if (spec === undefined) throw new Error(`settings card has no field ${field}`)
483
+ return spec
484
+ }
485
+
486
+ private snapshotOf(): ConfigFormSnapshot<T> {
487
+ return this.scope.getSnapshot()
488
+ }
489
+
490
+ private sectionValue(field: string): unknown {
491
+ return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
492
+ }
493
+
494
+ private baseValue(field: string): unknown {
495
+ return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
496
+ }
497
+
498
+ private userLayer(): Record<string, unknown> | undefined {
499
+ return this.snapshotOf().user as Record<string, unknown> | undefined
500
+ }
501
+
502
+ private stored(field: string): boolean {
503
+ const user = this.userLayer()
504
+ return user !== undefined && Object.hasOwn(user, field)
505
+ }
506
+
507
+ private publish(): void {
508
+ for (const listener of this.listeners) listener()
509
+ }
510
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The usage page's settings card: the plugin switch and the three display
3
+ * rows, written through the family card form so a pick lands at once and a
4
+ * typed value after a pause, exactly as the other Web Extensions pages do.
5
+ * @module @linxin666/dsh-usage/client/usage-settings-card
6
+ */
7
+
8
+ import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
9
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
10
+ import { CardForm, booleanField, choiceField, numberField, type CardActions, type CardShell, type FieldState } from './settings-form.ts'
11
+
12
+ /** The settings fields this page edits (the namespace's own schema). */
13
+ export interface UsageSettings {
14
+ enabled?: boolean
15
+ pollIntervalSec?: number
16
+ /** Currency the spend figures are displayed in; the ledger stays CNY. */
17
+ spendCurrency?: 'CNY' | 'USD'
18
+ /** CNY per USD, consulted only while the display currency is USD. */
19
+ cnyPerUsd?: number
20
+ }
21
+
22
+ /** The display currencies the spend figures offer. */
23
+ export const SPEND_CURRENCIES = ['CNY', 'USD'] as const
24
+
25
+ /** Bounds of the poll interval, mirroring the host schema; the slider walks them in steps of 10 s. */
26
+ export const POLL_INTERVAL_RANGE = { min: 30, max: 3600, step: 10 }
27
+
28
+ /** What the settings card renders. */
29
+ export interface UsageSettingsCardState extends CardShell {
30
+ enabled: FieldState
31
+ pollIntervalSec: FieldState
32
+ spendCurrency: FieldState
33
+ cnyPerUsd: FieldState
34
+ }
35
+
36
+ /** The card's snapshot and its form actions, as the section receives them. */
37
+ export interface UsageSettingsCardFace extends CardActions {
38
+ store: SnapshotStore<UsageSettingsCardState>
39
+ }
40
+
41
+ /** Bridges the usage namespace onto the family card form for the plugin's lifetime. */
42
+ export class UsageSettingsCardController {
43
+ private readonly form: CardForm<UsageSettings>
44
+ private readonly store: SnapshotStore<UsageSettingsCardState>
45
+
46
+ /** @param scope - the bound configuration form of the usage namespace. */
47
+ constructor(scope: ConfigForm<UsageSettings>) {
48
+ this.form = new CardForm(scope, [
49
+ booleanField('enabled'),
50
+ numberField('pollIntervalSec', { integer: true, min: POLL_INTERVAL_RANGE.min }),
51
+ choiceField('spendCurrency', SPEND_CURRENCIES),
52
+ numberField('cnyPerUsd', { min: 0.01 }),
53
+ ])
54
+ this.store = this.form.bind(() => ({
55
+ ...this.form.shell(),
56
+ enabled: this.form.field('enabled'),
57
+ pollIntervalSec: this.form.field('pollIntervalSec'),
58
+ spendCurrency: this.form.field('spendCurrency'),
59
+ cnyPerUsd: this.form.field('cnyPerUsd'),
60
+ }))
61
+ }
62
+
63
+ /** The face the section's slot entry injects. */
64
+ inject(): UsageSettingsCardFace {
65
+ return { store: this.store, ...this.form.actions() }
66
+ }
67
+
68
+ /** Release the form subscription; the plugin disposer calls this on teardown. */
69
+ dispose(): void {
70
+ this.form.dispose()
71
+ }
72
+ }