@linxin666/dsh-pet 0.1.14 → 0.1.15

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.
Files changed (44) hide show
  1. package/lib/client.js +29 -16
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +355 -251
  4. package/lib/invariant.js +1 -1
  5. package/lib/{state-P-wsVJ6O.js → state-CFyJv0sQ.js} +15 -1
  6. package/lib/types/affinity.d.ts +15 -0
  7. package/lib/types/affinity.d.ts.map +1 -1
  8. package/lib/types/affinity.js +14 -0
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +27 -7
  12. package/lib/types/client/index.d.ts +1 -1
  13. package/lib/types/client/index.js +3 -3
  14. package/lib/types/client/settings-form.d.ts +14 -5
  15. package/lib/types/client/settings-form.d.ts.map +1 -1
  16. package/lib/types/client/settings-form.js +38 -10
  17. package/lib/types/dsh-home.d.ts +17 -0
  18. package/lib/types/dsh-home.d.ts.map +1 -0
  19. package/lib/types/dsh-home.js +34 -0
  20. package/lib/types/event-projection.d.ts +36 -0
  21. package/lib/types/event-projection.d.ts.map +1 -0
  22. package/lib/types/event-projection.js +98 -0
  23. package/lib/types/ledger.d.ts +77 -0
  24. package/lib/types/ledger.d.ts.map +1 -0
  25. package/lib/types/ledger.js +139 -0
  26. package/lib/types/persist.d.ts +5 -1
  27. package/lib/types/persist.d.ts.map +1 -1
  28. package/lib/types/persist.js +7 -3
  29. package/lib/types/service.d.ts +15 -46
  30. package/lib/types/service.d.ts.map +1 -1
  31. package/lib/types/service.js +47 -204
  32. package/package.json +1 -1
  33. package/src/affinity.ts +33 -0
  34. package/src/client/PluginSettingsCard.tsx +83 -17
  35. package/src/client/index.ts +3 -3
  36. package/src/client/settings-card.module.css +6 -0
  37. package/src/client/settings-form.ts +41 -9
  38. package/src/dsh-home.test.ts +35 -0
  39. package/src/dsh-home.ts +36 -0
  40. package/src/event-projection.ts +128 -0
  41. package/src/ledger.test.ts +67 -0
  42. package/src/ledger.ts +183 -0
  43. package/src/persist.ts +7 -3
  44. package/src/service.ts +60 -274
@@ -1,136 +1,44 @@
1
1
  /**
2
- * Pet host service — the `pet.*` RPC domain. Owns the state machine wiring
3
- * (projects official session events and accepts legacy `activity/status`), the
4
- * affinity ledger, and the persisted display config. The API gateway maps
5
- * this service's methods onto `pet.state` / `pet.interact` /
6
- * `pet.setVisible` / `pet.setConfig` for browser consumers.
2
+ * Pet host service — the `pet.*` RPC domain. A composition facade: it wires
3
+ * the pure event projection (`event-projection`) onto the state machine,
4
+ * delegates the affinity economy to the ledger (`ledger`), and routes
5
+ * persistence through `persist`. The API gateway maps these methods onto
6
+ * `pet.state` / `pet.interact` / `pet.setVisible` / `pet.setConfig` for
7
+ * browser consumers.
7
8
  * @module @linxin666/dsh-pet/service
8
9
  */
9
10
  import { Service } from '@deepseek-ai/cordis';
10
- import { applyInteraction, applyTurnReward, defaultAffinityConfig, rankOf, } from "./affinity.js";
11
- import { loadPetPersist, petHomeDir, savePetPersist, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, DISPLAY_INSET_MAX, PET_NAME_MAX_LENGTH, } from "./persist.js";
12
- import { defaultTreatConfig, settleTreatGrants, consumeTreat, } from "./treats.js";
11
+ import { emptyProjectionRuntime, isActivityPhase, projectOfficialEvent, } from "./event-projection.js";
12
+ import { PetLedger } from "./ledger.js";
13
+ import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN, PET_NAME_MAX_LENGTH, loadPetPersist, petHomeDir, savePetPersist, } from "./persist.js";
13
14
  import { defaultPetStateConfig, PetStateMachine, } from "./state.js";
14
15
  /** Settings namespace of the pet capability. Spelled here rather than imported: the browser half spells the same value. */
15
16
  export const PET_SETTINGS_NAMESPACE = 'pet';
16
- /** Keep tool names readable inside the compact status bubble. */
17
- function displayToolName(name) {
18
- const compact = name.replace(/\s+/g, ' ').trim() || '工具';
19
- return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`;
20
- }
21
- /** Whether a legacy phase is part of the pet's supported vocabulary. */
22
- function isActivityPhase(phase) {
23
- return ['idle', 'waiting', 'thinking', 'tool', 'review', 'done', 'failed'].includes(phase);
24
- }
25
- /**
26
- * Project the durable DSH session vocabulary into the pet's visual phases.
27
- * Unknown and log-only events do not disturb the last meaningful activity.
28
- */
29
- function projectOfficialEvent(event, runtime) {
30
- switch (event.type) {
31
- case 'turn/start':
32
- runtime.activeTools.clear();
33
- runtime.stepHadFailure = false;
34
- return { input: { phase: 'waiting', line: '准备开始' } };
35
- case 'step/start':
36
- runtime.activeTools.clear();
37
- runtime.stepHadFailure = false;
38
- return { input: { phase: 'waiting', line: '等待模型响应' } };
39
- case 'assistant/chunk': {
40
- const { chunk } = event.data;
41
- if (chunk.type === 'reasoning-delta' && chunk.text.length > 0) {
42
- return { input: { phase: 'thinking', line: '正在思考' } };
43
- }
44
- if (chunk.type === 'text-delta' && chunk.text.length > 0) {
45
- return { input: { phase: 'review', line: '整理回复中' } };
46
- }
47
- return undefined;
48
- }
49
- case 'assistant/message':
50
- return { input: { phase: 'review', line: '整理回复中' } };
51
- case 'tool/call':
52
- runtime.activeTools.add(String(event.data.callId));
53
- return {
54
- input: {
55
- phase: 'tool',
56
- line: `正在使用 ${displayToolName(event.data.name)}`,
57
- },
58
- };
59
- case 'tool/result': {
60
- const block = event.data.message.content[0];
61
- runtime.activeTools.delete(String(event.data.message.source.callId));
62
- runtime.stepHadFailure ||= event.data.error !== undefined || block.isError === true;
63
- if (runtime.activeTools.size > 0) {
64
- return {
65
- input: {
66
- phase: 'tool',
67
- line: `还有 ${runtime.activeTools.size} 个工具运行中`,
68
- },
69
- };
70
- }
71
- return runtime.stepHadFailure
72
- ? { input: { phase: 'failed', line: '工具执行失败' } }
73
- : { input: { phase: 'thinking', line: '处理工具结果' } };
74
- }
75
- case 'turn/end': {
76
- runtime.activeTools.clear();
77
- switch (event.data.reason.kind) {
78
- case 'completed':
79
- return {
80
- input: { phase: 'done', line: '完成啦' },
81
- completedTurn: event.data.turn,
82
- };
83
- case 'error':
84
- return { input: { phase: 'failed', line: '执行失败' } };
85
- case 'max-tokens':
86
- return { input: { phase: 'failed', line: '达到输出上限' } };
87
- case 'interrupted':
88
- return { input: { phase: 'failed', line: '执行意外中断' } };
89
- case 'blocked':
90
- return { input: { phase: 'waiting', line: '等待继续' } };
91
- case 'aborted':
92
- return { input: { phase: 'idle', line: '已停止' } };
93
- default:
94
- // TurnEndReasonMap is merge-extensible; a newer ending must not
95
- // leave the pet showing stale in-progress work.
96
- return { input: { phase: 'idle' } };
97
- }
98
- }
99
- default:
100
- return undefined;
101
- }
102
- }
103
17
  /**
104
18
  * Cordis service exposing the pet RPC domain. Lazy: nothing is scanned or
105
- * written until a query or interaction arrives; event listeners update only
106
- * in-memory state, and persistence happens on interaction/config changes
107
- * plus every completed turn.
19
+ * written until an economic event or interaction arrives; event listeners
20
+ * update only in-memory state, and persistence happens on economic changes
21
+ * (turn rewards, feeds, config/name changes) — never on a read.
108
22
  */
109
23
  export class PetService extends Service {
110
24
  static inject = [];
111
25
  machine;
112
- affinityConfig;
113
- treatConfig;
26
+ ledger;
114
27
  persistDir;
115
- persist;
116
- /** Completed turns already rewarded, per session (turn numbers are per-session). */
117
- rewardedTurns = new Map();
118
28
  enabled;
119
29
  disposeActivity;
120
30
  /** Session whose most recent meaningful event currently drives the global pet. */
121
31
  displaySession;
122
32
  sessionActivity = new WeakMap();
123
- lastLegacyTurnRewardAt = 0;
124
33
  constructor(ctx, config = {}) {
125
34
  super(ctx, 'pet');
126
35
  this.persistDir = config.persistDir ?? petHomeDir();
127
- this.affinityConfig = { ...defaultAffinityConfig, ...(config.affinity ?? {}) };
128
- this.treatConfig = { ...defaultTreatConfig, ...(config.treats ?? {}) };
36
+ const ledgerConfig = { affinity: config.affinity, treats: config.treats };
37
+ this.ledger = new PetLedger(loadPetPersist(this.persistDir), ledgerConfig);
129
38
  this.machine = new PetStateMachine({
130
39
  ...defaultPetStateConfig,
131
40
  ...(config.state ?? {}),
132
41
  });
133
- this.persist = loadPetPersist(this.persistDir);
134
42
  this.enabled = config.enabled ?? true;
135
43
  this.syncActivity();
136
44
  }
@@ -144,11 +52,11 @@ export class PetService extends Service {
144
52
  }
145
53
  /** Current persisted display config (read-only view). */
146
54
  display() {
147
- return { ...this.persist.display };
55
+ return { ...this.ledger.snapshot.display };
148
56
  }
149
57
  /** Current persisted pet name (read-only view). */
150
58
  petName() {
151
- return this.persist.name;
59
+ return this.ledger.snapshot.name;
152
60
  }
153
61
  /** Start or stop the session-activity listeners that drive the pet. */
154
62
  setEnabled(enabled) {
@@ -210,11 +118,7 @@ export class PetService extends Service {
210
118
  activityRuntime(session) {
211
119
  let runtime = this.sessionActivity.get(session);
212
120
  if (runtime === undefined) {
213
- runtime = {
214
- activeTools: new Set(),
215
- officialEventsSeen: false,
216
- stepHadFailure: false,
217
- };
121
+ runtime = emptyProjectionRuntime();
218
122
  this.sessionActivity.set(session, runtime);
219
123
  }
220
124
  return runtime;
@@ -228,52 +132,28 @@ export class PetService extends Service {
228
132
  /** RPC: pet or feed the pet. */
229
133
  async interact(kind) {
230
134
  const nowMs = Date.now();
231
- // Feeding consumes a treat: settle the economy first (work + time
232
- // output since the last settlement), then gate on the feed cooldown
233
- // BEFORE spending stock — a feed inside the cooldown must not burn a
234
- // treat for nothing.
235
- if (kind === 'feed')
236
- this.settleTreats(nowMs);
237
- const outcome = applyInteraction(this.persist.affinity, kind, nowMs, this.affinityConfig);
238
- if (kind === 'feed' && !outcome.accepted) {
239
- return { reaction: outcome.reaction, delta: 0, affinity: this.affinityView(this.persist.affinity) };
240
- }
241
- if (kind === 'feed') {
242
- const consume = consumeTreat(this.persist.treats);
243
- if (!consume.ok) {
244
- const affinity = this.affinityView(this.persist.affinity);
245
- return {
246
- reaction: '没有小鱼干了,多陪鲸鱼娘工作一会儿吧~',
247
- delta: 0,
248
- affinity,
249
- };
250
- }
251
- this.persist = { ...this.persist, treats: consume.ledger };
252
- }
253
- if (outcome.accepted) {
254
- this.persist = { ...this.persist, affinity: outcome.affinity };
135
+ const result = this.ledger.interact(kind, nowMs);
136
+ if (this.ledger.takeDirty())
255
137
  this.flush();
256
- }
257
- const affinity = this.affinityView(outcome.affinity);
258
- return { reaction: outcome.reaction, delta: outcome.delta, affinity };
138
+ return result;
259
139
  }
260
140
  /** RPC: show or hide the pet. */
261
141
  async setVisible(visible) {
262
- this.persist = { ...this.persist, display: { ...this.persist.display, visible } };
142
+ this.ledger.setDisplay({ ...this.ledger.snapshot.display, visible });
263
143
  this.flush();
264
144
  this.syncSettingsFromPet();
265
- return { ok: true, display: this.persist.display };
145
+ return { ok: true, display: this.ledger.snapshot.display };
266
146
  }
267
147
  /** RPC: update display config (size / position). Values are clamped to whole pixels. */
268
148
  async setConfig(patch) {
269
- const next = { ...this.persist.display, ...patch };
149
+ const next = { ...this.ledger.snapshot.display, ...patch };
270
150
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)));
271
151
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)));
272
152
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)));
273
- this.persist = { ...this.persist, display: next };
153
+ this.ledger.setDisplay(next);
274
154
  this.flush();
275
155
  this.syncSettingsFromPet();
276
- return { ok: true, display: this.persist.display };
156
+ return { ok: true, display: this.ledger.snapshot.display };
277
157
  }
278
158
  /** RPC: rename the pet (trimmed, 1–20 chars). */
279
159
  async setName(name) {
@@ -282,7 +162,7 @@ export class PetService extends Service {
282
162
  return { ok: false, error: 'name-empty' };
283
163
  if (trimmed.length > PET_NAME_MAX_LENGTH)
284
164
  return { ok: false, error: 'name-too-long' };
285
- this.persist = { ...this.persist, name: trimmed };
165
+ this.ledger.setName(trimmed);
286
166
  this.flush();
287
167
  this.syncSettingsFromPet();
288
168
  return { ok: true, name: trimmed };
@@ -294,12 +174,13 @@ export class PetService extends Service {
294
174
  * @param section - the resolved settings section.
295
175
  */
296
176
  applySettingsSection(section) {
297
- const next = { ...this.persist.display };
177
+ const next = { ...this.ledger.snapshot.display };
298
178
  next.visible = section.visible && (section.enabled ?? true);
299
179
  next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)));
300
180
  next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)));
301
181
  next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)));
302
- this.persist = { ...this.persist, display: next, name: section.name.trim() };
182
+ this.ledger.setDisplay(next);
183
+ this.ledger.setName(section.name.trim());
303
184
  this.flush();
304
185
  }
305
186
  /** Mirror the persisted display config into the settings document (best-effort). */
@@ -307,86 +188,48 @@ export class PetService extends Service {
307
188
  const settings = this.ctx.get('settings', false);
308
189
  if (settings === undefined)
309
190
  return;
191
+ const snapshot = this.ledger.snapshot;
310
192
  void settings.update(PET_SETTINGS_NAMESPACE, {
311
- visible: this.persist.display.visible,
312
- size: this.persist.display.size,
313
- right: this.persist.display.right,
314
- bottom: this.persist.display.bottom,
315
- name: this.persist.name,
193
+ visible: snapshot.display.visible,
194
+ size: snapshot.display.size,
195
+ right: snapshot.display.right,
196
+ bottom: snapshot.display.bottom,
197
+ name: snapshot.name,
316
198
  }).catch(() => {
317
199
  // A settings write failure must not break the pet's own persistence.
318
200
  });
319
201
  }
320
202
  /** Award the turn reward once per completed turn (idempotent per session + turn). */
321
203
  rewardTurn(sessionId, turn) {
322
- const last = this.rewardedTurns.get(sessionId) ?? 0;
323
- if (turn <= last)
324
- return;
325
- this.rewardedTurns.set(sessionId, turn);
326
- this.applyTurnReward();
204
+ if (this.ledger.rewardTurn(sessionId, turn, Date.now()))
205
+ this.flush();
327
206
  }
328
207
  /** Preserve turn rewards for installations that only emit legacy activity. */
329
208
  rewardLegacyTurn() {
330
- const nowMs = Date.now();
331
- // A legacy `done` snapshot may repeat during the celebration window.
332
- if (nowMs - this.lastLegacyTurnRewardAt < 5_000)
333
- return;
334
- this.lastLegacyTurnRewardAt = nowMs;
335
- this.applyTurnReward();
336
- }
337
- /** Persist one accepted completed-turn reward. */
338
- applyTurnReward() {
339
- this.persist = { ...this.persist, affinity: applyTurnReward(this.persist.affinity, this.affinityConfig) };
340
- this.flush();
341
- }
342
- /**
343
- * Settle the treat economy (work + time output since the last settlement)
344
- * and persist whenever the ledger changed. A zero-gain first settlement
345
- * still starts the time clock (anchor write), which is what lets the
346
- * 30-minute time output ever accrue.
347
- */
348
- settleTreats(nowMs) {
349
- const settlement = settleTreatGrants(this.persist.treats, this.persist.affinity.turns, nowMs, this.treatConfig);
350
- if (settlement.ledger !== this.persist.treats) {
351
- this.persist = { ...this.persist, treats: settlement.ledger };
209
+ if (this.ledger.rewardLegacyTurn(Date.now()))
352
210
  this.flush();
353
- }
354
211
  }
355
212
  view() {
356
213
  const snapshot = this.machine.render();
357
- // Time-output treats accrue while the host is idle too; settle on read.
358
- this.settleTreats(Date.now());
214
+ // Read-only: the ledger settles on economic events only, never on a read,
215
+ // so polling the state cannot trigger pet.json writes.
359
216
  return {
360
217
  animation: snapshot.animation,
361
218
  ...(snapshot.bubble === undefined ? {} : { bubble: snapshot.bubble }),
362
219
  phase: snapshot.phase,
363
220
  sessionActive: snapshot.sessionActive,
364
- affinity: this.affinityView(this.persist.affinity),
365
- display: { ...this.persist.display },
366
- name: this.persist.name,
221
+ affinity: this.ledger.affinityView(Date.now()),
222
+ display: { ...this.ledger.snapshot.display },
223
+ name: this.ledger.snapshot.name,
367
224
  treats: {
368
- stocked: this.persist.treats.treats,
369
- max: this.treatConfig.maxTreats,
225
+ stocked: this.ledger.snapshot.treats.treats,
226
+ max: this.ledger.treatMax,
370
227
  },
371
228
  };
372
229
  }
373
- affinityView(affinity) {
374
- const nowMs = Date.now();
375
- const rank = rankOf(affinity.points);
376
- return {
377
- points: affinity.points,
378
- rank: rank.name,
379
- rankEmoji: rank.emoji,
380
- pets: affinity.pets,
381
- feeds: affinity.feeds,
382
- turns: affinity.turns,
383
- petCooldown: nowMs - affinity.lastPetAt < this.affinityConfig.petCooldownMs,
384
- feedCooldown: nowMs - affinity.lastFeedAt < this.affinityConfig.feedCooldownMs,
385
- };
386
- }
387
230
  flush() {
388
231
  try {
389
- savePetPersist(this.persist, this.persistDir);
232
+ savePetPersist(this.ledger.snapshot, this.persistDir);
390
233
  }
391
234
  catch {
392
235
  // Persistence is best-effort; the in-memory ledger keeps working.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-pet",
3
3
  "description": "鲸鱼娘宠物插件 for the dsh web GUI: a soft healing whale-girl companion that reacts to model activity (idle/waiting/thinking/tool/done), with petting/feeding interactions and an affinity score",
4
- "version": "0.1.14",
4
+ "version": "0.1.15",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
package/src/affinity.ts CHANGED
@@ -84,6 +84,39 @@ export function rankOf(points: number): (typeof AFFINITY_RANKS)[number] {
84
84
  return rank
85
85
  }
86
86
 
87
+ /** Read-only affinity snapshot suited for the RPC view shape. */
88
+ export interface PetAffinityView {
89
+ points: number
90
+ rank: string
91
+ rankEmoji: string
92
+ pets: number
93
+ feeds: number
94
+ turns: number
95
+ /** True while the pet interaction is inside its cooldown. */
96
+ petCooldown: boolean
97
+ /** True while the feed is inside its cooldown. */
98
+ feedCooldown: boolean
99
+ }
100
+
101
+ /** Derive the read-only view of one affinity state at a wall-clock instant. */
102
+ export function affinityViewOf(
103
+ state: AffinityState,
104
+ nowMs: number,
105
+ config: AffinityConfig = defaultAffinityConfig,
106
+ ): PetAffinityView {
107
+ const rank = rankOf(state.points)
108
+ return {
109
+ points: state.points,
110
+ rank: rank.name,
111
+ rankEmoji: rank.emoji,
112
+ pets: state.pets,
113
+ feeds: state.feeds,
114
+ turns: state.turns,
115
+ petCooldown: nowMs - state.lastPetAt < config.petCooldownMs,
116
+ feedCooldown: nowMs - state.lastFeedAt < config.feedCooldownMs,
117
+ }
118
+ }
119
+
87
120
  function clamp(points: number): number {
88
121
  return Math.min(AFFINITY_MAX, Math.max(0, points))
89
122
  }
@@ -1,25 +1,44 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
- * Shared chrome for the plugin settings card: a disclosure header naming the
3
- * plugin and what its settings govern, the controls inside, and the save that
4
- * writes them. Renders nothing while the namespace is unavailable — a
3
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
4
+ * the plugin and what its settings govern, the controls inside, and the save
5
+ * that writes them. Renders nothing while the namespace is unavailable — a
5
6
  * deployment that does not compose the owning plugin should show no trace of
6
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
- * slice (this package must not depend on a sibling UI package).
7
+ * it. Inlined into each consumer's client bundle; mirrors the official
8
+ * ui-plugin-config PluginCard in a self-contained slice.
8
9
  */
9
10
 
10
11
  import { useState, type ReactNode } from 'react'
11
12
  import type { CardShell } from './settings-form.ts'
12
- import type { SettingsCardKey } from './locales.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
15
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
16
+ export const CARD_COPY_KEYS = [
17
+ 'settings.collapse',
18
+ 'settings.expand',
19
+ 'settings.notExposed',
20
+ 'settings.unsaved',
21
+ 'settings.readOnly',
22
+ 'settings.saveFailed',
23
+ 'settings.discard',
24
+ 'settings.save',
25
+ 'settings.saving',
26
+ ] as const
27
+
28
+ /** Copy key the card chrome itself reads. */
29
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
30
+
31
+ /** Key domain for the plugin's own copy (inferred per consumer). */
32
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
33
+
15
34
  /** Card chrome shared by every plugin settings card. */
16
- export interface PluginSettingsCardProps {
35
+ export interface PluginSettingsCardProps<TKey extends string = string> {
17
36
  /** Locale reader for this card's copy. */
18
- t: (key: SettingsCardKey) => string
37
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
19
38
  /** Locale key of the plugin's name. */
20
- titleKey: SettingsCardKey
39
+ titleKey: TKey
21
40
  /** Locale key of the line describing what this plugin's settings govern. */
22
- descriptionKey: SettingsCardKey
41
+ descriptionKey: TKey
23
42
  /** The card's form state: availability, writability, and what a save would do. */
24
43
  state: CardShell
25
44
  /** Write every staged edit. */
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
35
54
  * @param props - the plugin's copy keys, its form state, and its controls.
36
55
  * @returns the card, or nothing while the namespace is still loading.
37
56
  */
38
- export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
39
58
  const [open, setOpen] = useState(false)
40
59
  const { state } = props
41
60
  if (!state.available) return null
42
61
  const title = props.t(props.titleKey)
62
+ const description = props.t(props.descriptionKey)
43
63
  const blocked = !state.dirty || state.invalid || state.saving
44
64
  const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
45
65
  // The namespace exists but the Host does not serve it to this client (the
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
77
  onClick={() => { setOpen(!open) }}
58
78
  >
59
79
  <span className={css.headText}>
60
- <span className={css.name}>{title}</span>
61
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
80
+ <span className={css.name} title={title}>{title}</span>
81
+ <span className={css.description} title={description}>{description}</span>
62
82
  </span>
63
83
  <svg
64
84
  width="14"
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
94
114
  onClick={() => { setOpen(!open) }}
95
115
  >
96
116
  <span className={css.headText}>
97
- <span className={css.name}>{title}</span>
98
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
117
+ <span className={css.name} title={title}>{title}</span>
118
+ <span className={css.description} title={description}>{description}</span>
99
119
  </span>
100
- {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
120
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
101
121
  <svg
102
122
  width="14"
103
123
  height="14"
@@ -259,4 +279,50 @@ export function BooleanField(props: FieldProps & {
259
279
  <p className={css.hint}>{props.hint}</p>
260
280
  </div>
261
281
  )
262
- }
282
+ }
283
+
284
+ /** A staged enumerated field rendered as a select. */
285
+ export function ChoiceField(props: FieldProps & {
286
+ /** Copy for the inherit option (draft text is the empty string). */
287
+ inheritLabel: string
288
+ /** Choices rendered in order; `value` is the draft/stored text. */
289
+ choices: ReadonlyArray<{ value: string; label: string }>
290
+ }) {
291
+ return (
292
+ <div className={css.field}>
293
+ <div className={css.head}>
294
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
295
+ {props.overridden
296
+ ? (
297
+ <span className={css.badges}>
298
+ <span className={css.badge}>{props.overriddenLabel}</span>
299
+ <button
300
+ type="button"
301
+ className={css.reset}
302
+ disabled={props.disabled}
303
+ onClick={props.onReset}
304
+ >
305
+ {props.resetLabel}
306
+ </button>
307
+ </span>
308
+ )
309
+ : null}
310
+ </div>
311
+ <select
312
+ id={props.id}
313
+ className={css.select}
314
+ value={props.text}
315
+ disabled={props.disabled}
316
+ onChange={(event) => { props.onEdit(event.target.value) }}
317
+ >
318
+ <option value="">{props.inheritLabel}</option>
319
+ {props.choices.map(choice => (
320
+ <option key={choice.value} value={choice.value}>{choice.label}</option>
321
+ ))}
322
+ </select>
323
+ <p className={props.invalid ? css.invalid : css.hint}>
324
+ {props.invalid ? props.invalidLabel : props.hint}
325
+ </p>
326
+ </div>
327
+ )
328
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * dsh-pet browser half — mounts the whale-girl as a global floating surface
3
3
  * and drives it from the host's same-origin `/api/pet/*` JSON endpoints: poll
4
- * the host snapshot (~800 ms), forward interactions, persist drag positions.
4
+ * the host snapshot (~2 s), forward interactions, persist drag positions.
5
5
  * The pet is host-global (no session dimension), so it mounts directly onto
6
6
  * `document.body` via a single React root rather than a session-scoped slot —
7
7
  * on the new-conversation screen no session exists, and a dock-mounted pet
@@ -61,7 +61,7 @@ const petApi: PetHttpApi = {
61
61
  }
62
62
 
63
63
  /** Poll interval for the host snapshot. */
64
- const POLL_MS = 800
64
+ const POLL_MS = 2000
65
65
 
66
66
  /** Settings namespace the pet settings card edits (the Host plugin registers it). */
67
67
  const PET_SETTINGS_NS = 'pet'
@@ -161,7 +161,7 @@ export function apply(ctx: ClientContext): void {
161
161
  // change while the page is hidden, so a background interval would
162
162
  // only burn RPCs (browser throttling is an unreliable backstop).
163
163
  // Coming back to the tab refreshes the pet immediately instead of
164
- // waiting out the next 800 ms cycle.
164
+ // waiting out the next 2 s cycle.
165
165
  let timer: number | undefined
166
166
  const stop = (): void => {
167
167
  if (timer !== undefined) {
@@ -1,3 +1,4 @@
1
+ /* Generated by scripts/sync-shared.mjs from shared/client/settings/settings-card.module.css. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". */
1
2
  /* Plugin settings card chrome + staged form fields.
2
3
  * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
4
  * same semantic tokens, radius, typography and states so family cards read
@@ -229,6 +230,11 @@
229
230
  outline-offset: 2px;
230
231
  }
231
232
 
233
+ .reset:focus-visible {
234
+ outline: 2px solid var(--dsw-alias-brand-primary);
235
+ outline-offset: 2px;
236
+ }
237
+
232
238
  .input,
233
239
  .select {
234
240
  border: 1px solid var(--dsw-alias-border-l2);