dsh-web-icon-indicator 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -102,15 +102,19 @@ service under the `web-icon-indicator` namespace (a schemastery schema in
102
102
 
103
103
  - **Web GUI:** open **设置 → 插件 → 插件配置** — a *Favicon indicator* card
104
104
  edits the same keys (asking/done hold, and per-state effect / colors /
105
- cycle), staged and saved through the settings transport.
105
+ cycle), staged and saved through the settings transport. Each state is a
106
+ collapsible row showing a color dot and a one-line summary (`blink ·
107
+ #E5484D ⇄ #FACC15 · 400ms`); expanding a row reveals its three fields, and
108
+ the colors field previews parsed swatches live.
106
109
  - **Persistence:** values land in the profile's `settings.yaml` (default
107
110
  `~/.dsh/settings.yaml`) as a `web-icon-indicator:` section. The composition
108
111
  entry stays the `base` layer; resolution order is schema defaults →
109
112
  composition entry → settings document user layer.
110
- - **No server restart** for host-side timing changes (`askingHoldMs` /
111
- `doneHoldMs` apply live). Visual changes need a tab reload — the injected
112
- browser script bakes config at injection time, and the card re-injects it on
113
- the next page load.
113
+ - **No server restart, no tab reload** for settings-card saves: `askingHoldMs` /
114
+ `doneHoldMs` apply live host-side, and per-state visual config (effect /
115
+ colors / cycle) is synced into the running tab through the status poll within
116
+ ~1 s. Only code-level default changes in `lib/index.js` need a tab reload (or
117
+ a DSH web rebuild).
114
118
  - The browser half is a hand-written `lib/client.js` (ModuleLoader factory
115
119
  format — no build step, no runtime deps beyond the shell's `react`). The DSH
116
120
  client scanner picks a new `dsh.client` declaration up on the next profile
@@ -124,7 +128,7 @@ service under the `web-icon-indicator` namespace (a schemastery schema in
124
128
  - Status is aggregated across live `agents.list()` with priority `asking > running > done > idle`. The aggregation runs a `reconcile()` step on every request to detect running → idle transitions, because `agent/status`'s idle delivery is not guaranteed at turn end.
125
129
  - `ask_user_question` tool calls (via `tools/pre-execute` / `tools/result`) flip the session into `asking` with a configurable minimum-hold so the icon stays visible even when the user answers immediately.
126
130
  - Permission / **sandbox-interception** waits are also surfaced as `asking`: when the agent hits a sandbox denial and escalates (`sandbox_permissions` + `justification`), or any other tool asks for approval, the approval service appends an `approval/asked` session event and blocks the agent until you decide. The plugin watches `session/event` (with an authoritative fold over the live session log as a fallback) and pins the session into the `asking` state for that whole wait, clearing it on `approval/decided`.
127
- - The browser script polls `/dsh-web-icon-status.json` once a second, fetches `base.svg` once, and then on every `requestAnimationFrame` tick rebuilds the favicon as a `data:image/svg+xml,…` URI — replacing the `__COLOR__` placeholder with the state's configured color and applying the state's configured effect. Browsers don't play favicon SVG CSS animations, so all motion is JS-driven. Because browsers pause `requestAnimationFrame` in hidden tabs, the poll also repaints a wall-clock frame for animated states, so background tabs keep animating (coarsely) instead of freezing; full-speed animation resumes when the tab is visible again. The poll also survives host restarts: a transient fetch failure restores the original icon and retries on the next tick (the SPA reconnects in place, so the icon comes back without a manual refresh).
131
+ - The browser script polls `/dsh-web-icon-status.json` once a second, fetches `base.svg` once, and then on every `requestAnimationFrame` tick rebuilds the favicon as a `data:image/svg+xml,…` URI — replacing the `__COLOR__` placeholder with the state's configured color and applying the state's configured effect. The status response also echoes the current per-state visual config, so a settings save reaches the running tab on the next poll (~1 s) without a reload. Browsers don't play favicon SVG CSS animations, so all motion is JS-driven. Because browsers pause `requestAnimationFrame` in hidden tabs, the poll also repaints a wall-clock frame for animated states, so background tabs keep animating (coarsely) instead of freezing; full-speed animation resumes when the tab is visible again. The poll also survives host restarts: a transient fetch failure restores the original icon and retries on the next tick (the SPA reconnects in place, so the icon comes back without a manual refresh).
128
132
 
129
133
  ## Caveats
130
134
 
package/README.zh.md CHANGED
@@ -101,13 +101,16 @@ config:
101
101
 
102
102
  - **Web GUI:** 打开 **设置 → 插件 → 插件配置**,会出现 *标签页图标指示器*
103
103
  卡片,可编辑同样的键(提问/完成驻留,以及每个状态的特效 / 颜色 / 周期),
104
- 通过 settings 传输层暂存并保存。
104
+ 通过 settings 传输层暂存并保存。每个状态是一行可折叠条目,带主色圆点和一行
105
+ 摘要(如 `blink · #E5484D ⇄ #FACC15 · 400ms`);展开该行才显示它的三个字段,
106
+ 颜色输入框旁会实时预览解析出的色块。
105
107
  - **持久化:** 值写入 profile 的 `settings.yaml`(默认 `~/.dsh/settings.yaml`)
106
108
  的 `web-icon-indicator:` 段。合成条目仍是 `base` 层;解析顺序为 schema 默认值
107
109
  → 合成条目 → 设置文档用户层。
108
- - **无需重启服务器**即可让主机侧计时(`askingHoldMs` / `doneHoldMs`)生效;
109
- 视觉变化需要重载标签页——注入的浏览器脚本在注入时烘焙配置,卡片会在下次
110
- 页面加载时重新注入。
110
+ - **无需重启服务器、无需刷新标签页**即可让设置卡片的修改生效:`askingHoldMs` /
111
+ `doneHoldMs` 在主机侧即时生效;各状态的视觉配置(特效 / 颜色 / 周期)会随状态
112
+ 轮询同步进正在运行的标签页,约 1 秒内生效。只有改 `lib/index.js` 里的代码级
113
+ 默认值才需要重载标签页(或重新构建 DSH Web)。
111
114
  - 浏览器半区是手写的 `lib/client.js`(ModuleLoader factory 格式——无构建步骤、
112
115
  无额外运行期依赖,仅用 shell 自带的 `react`)。DSH 客户端扫描器会在下次启动
113
116
  profile 时识别新的 `dsh.client` 声明。
@@ -119,7 +122,7 @@ config:
119
122
  - 状态按 `agents.list()` 聚合,优先级 `asking > running > done > idle`。每次请求都会执行一次 `reconcile()` 检测 running → idle 的转换,因为 `agent/status` 的 idle 事件在回合结束时并不保证送达。
120
123
  - `ask_user_question` 工具调用(通过 `tools/pre-execute` / `tools/result`)把会话置为 `asking`,带可配置的最小保持时长,即使你立刻回答,图标也会保持可见。
121
124
  - 权限 / **沙箱拦截**等待同样会显示为 `asking`:当 agent 命中沙箱拒绝并请求提权(`sandbox_permissions` + `justification`),或其他工具需要征得同意时,审批服务会先写入一条 `approval/asked` 会话事件并阻塞 agent,直到你做出决定。插件监听 `session/event`(并以实时会话日志的权威折叠作为兜底)在整个等待期间将会话置为 `asking` 状态,收到 `approval/decided` 后清除。
122
- - 浏览器脚本每秒轮询 `/dsh-web-icon-status.json`,首次获取 `base.svg`,然后每个 `requestAnimationFrame` 周期把 favicon 重建为 `data:image/svg+xml,…` URI——把 `__COLOR__` 占位符替换为状态配置的颜色,并应用该状态配置的特效。浏览器不会播放 SVG favicon 的 CSS 动画,所以一切动画都由 JS 驱动。由于浏览器在**隐藏(后台)标签页会暂停 `requestAnimationFrame`**,轮询还会为动画态补绘一帧按墙钟时间计算的画面——后台标签页保持粗粒度动画(约每 1 秒)而不会冻结,切回前台后恢复满速动画。轮询还能**扛住 host 重启**:瞬时请求失败时先还原原始图标,并在下一个 tick 重试(SPA 原地重连,无需手动刷新图标即可恢复)。
125
+ - 浏览器脚本每秒轮询 `/dsh-web-icon-status.json`,首次获取 `base.svg`,然后每个 `requestAnimationFrame` 周期把 favicon 重建为 `data:image/svg+xml,…` URI——把 `__COLOR__` 占位符替换为状态配置的颜色,并应用该状态配置的特效。状态响应还会携带当前的每状态视觉配置,因此设置保存后约 1 秒内(下一个轮询 tick)即同步到已打开的标签页,无需刷新。浏览器不会播放 SVG favicon 的 CSS 动画,所以一切动画都由 JS 驱动。由于浏览器在**隐藏(后台)标签页会暂停 `requestAnimationFrame`**,轮询还会为动画态补绘一帧按墙钟时间计算的画面——后台标签页保持粗粒度动画(约每 1 秒)而不会冻结,切回前台后恢复满速动画。轮询还能**扛住 host 重启**:瞬时请求失败时先还原原始图标,并在下一个 tick 重试(SPA 原地重连,无需手动刷新图标即可恢复)。
123
126
 
124
127
  ## 已知限制
125
128
 
package/lib/client.js CHANGED
@@ -49,7 +49,10 @@ window.__ModuleLoader__.load({
49
49
  askingHoldMsHint: "Minimum visibility of the asking icon before it settles.",
50
50
  doneHoldMs: "Done hold (ms)",
51
51
  doneHoldMsHint: "How long the done icon stays before returning to idle.",
52
- stateLabel: "State",
52
+ stateIdle: "Idle",
53
+ stateRunning: "Running",
54
+ stateAsking: "Asking",
55
+ stateDone: "Done",
53
56
  effect: "Effect",
54
57
  effectHint: "Animation for this state.",
55
58
  colors: "Colors",
@@ -75,7 +78,10 @@ window.__ModuleLoader__.load({
75
78
  askingHoldMsHint: "提问图标的最短可见时长。",
76
79
  doneHoldMs: "完成驻留(毫秒)",
77
80
  doneHoldMsHint: "完成图标停留多久后回到待机。",
78
- stateLabel: "状态",
81
+ stateIdle: "待机",
82
+ stateRunning: "运行中",
83
+ stateAsking: "提问",
84
+ stateDone: "完成",
79
85
  effect: "特效",
80
86
  effectHint: "该状态的动画效果。",
81
87
  colors: "颜色",
@@ -111,8 +117,13 @@ window.__ModuleLoader__.load({
111
117
  hint: { color: "var(--dsw-alias-label-tertiary)", fontSize: 12, lineHeight: "1.5", margin: 0 },
112
118
  invalidText: { color: "var(--dsw-alias-label-error)", fontSize: 12, lineHeight: "1.5", margin: 0 },
113
119
  select: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", height: 34, borderRadius: 8, padding: "0 8px", fontSize: 13, fontFamily: "inherit", color: "var(--dsw-alias-label-primary)" },
114
- group: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: 4, paddingTop: 4 },
115
- groupTitle: { color: "var(--dsw-alias-label-secondary)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: ".04em", margin: "10px 0 0" },
120
+ statesBlock: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: 4, paddingTop: 4 },
121
+ dot: { display: "inline-block", width: 10, height: 10, borderRadius: 999, border: "1px solid var(--dsw-alias-border-l2)" },
122
+ summary: { flex: 1, minWidth: 0, marginLeft: 8, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", color: "var(--dsw-alias-label-tertiary)", fontSize: 12, lineHeight: "24px" },
123
+ statePanel: { padding: "2px 0 12px 22px" },
124
+ inputRow: { display: "flex", alignItems: "center", gap: 8 },
125
+ swatches: { display: "inline-flex", alignItems: "center", gap: 4, flex: "none" },
126
+ swatch: { display: "inline-block", width: 14, height: 14, borderRadius: 4, border: "1px solid var(--dsw-alias-border-l2)" },
116
127
  badges: { display: "inline-flex", alignItems: "center", gap: 8 },
117
128
  badge: { whiteSpace: "nowrap", background: "var(--dsw-alias-bg-module-platform)", color: "var(--dsw-alias-label-secondary)", borderRadius: 999, padding: "1px 8px", fontSize: 11, fontWeight: 500, lineHeight: "17px" },
118
129
  footer: { borderTop: "1px solid var(--dsw-alias-border-l2)", display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 8, padding: "12px 0 4px" },
@@ -123,12 +134,12 @@ window.__ModuleLoader__.load({
123
134
  // the field row and the inner <input> flex. Primitives components carry no
124
135
  // CSS-module hooks for us (the bundle has no build step), so one guarded
125
136
  // style tag mirrors what the official cards' stylesheets do.
126
- var FORM_CSS_TAG = "dsh-web-icon-indicator/form.css";
137
+ var FORM_CSS_TAG = "dsh-web-icon-indicator/form.v2.css";
127
138
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(FORM_CSS_TAG) + "]") === null) {
128
139
  var cssTag = document.createElement("style");
129
140
  cssTag.dataset.plugin = "dsh-web-icon-indicator";
130
141
  cssTag.dataset.pluginCss = FORM_CSS_TAG;
131
- cssTag.textContent = ".dsh-wii-input{display:flex;align-items:center;width:100%}.dsh-wii-input>input{flex:1;min-width:0}";
142
+ cssTag.textContent = ".dsh-wii-input{display:flex;align-items:center;width:100%}.dsh-wii-input>input{flex:1;min-width:0}.dsh-wii-state+.dsh-wii-state{border-top:1px solid var(--dsw-alias-border-l2)}";
132
143
  document.head.appendChild(cssTag);
133
144
  }
134
145
 
@@ -152,38 +163,24 @@ window.__ModuleLoader__.load({
152
163
  );
153
164
  }
154
165
 
155
- function NumberInput(props) {
156
- // props: { id, value, onChange, invalid, disabled, invalidLabel, hint, label }
157
- return createElement(
158
- LabeledField,
159
- { label: props.label, hint: props.hint, invalid: props.invalid, invalidLabel: props.invalidLabel, separated: props.separated },
160
- createElement(primitives.Input, {
161
- id: props.id,
162
- type: "text",
163
- inputMode: "numeric",
164
- className: "dsh-wii-input",
165
- style: inputStyle(props.invalid),
166
- disabled: props.disabled,
167
- value: props.value,
168
- onChange: function (event) { props.onChange(event.target.value); },
169
- })
170
- );
171
- }
172
-
173
- function TextInput(props) {
166
+ function ValueInput(props) {
167
+ // props: { id, label, hint, invalidLabel, invalid, separated, disabled, value,
168
+ // placeholder, numeric, trailing, onChange }
169
+ var input = createElement(primitives.Input, {
170
+ id: props.id,
171
+ type: "text",
172
+ inputMode: props.numeric ? "numeric" : void 0,
173
+ className: "dsh-wii-input",
174
+ style: inputStyle(props.invalid),
175
+ disabled: props.disabled,
176
+ value: props.value,
177
+ placeholder: props.placeholder || "",
178
+ onChange: function (event) { props.onChange(event.target.value); },
179
+ });
174
180
  return createElement(
175
181
  LabeledField,
176
182
  { label: props.label, hint: props.hint, invalid: props.invalid, invalidLabel: props.invalidLabel, separated: props.separated },
177
- createElement(primitives.Input, {
178
- id: props.id,
179
- type: "text",
180
- className: "dsh-wii-input",
181
- style: inputStyle(props.invalid),
182
- disabled: props.disabled,
183
- value: props.value,
184
- placeholder: props.placeholder || "",
185
- onChange: function (event) { props.onChange(event.target.value); },
186
- })
183
+ props.trailing ? createElement("div", { style: styles.inputRow }, input, props.trailing) : input
187
184
  );
188
185
  }
189
186
 
@@ -204,6 +201,24 @@ window.__ModuleLoader__.load({
204
201
  );
205
202
  }
206
203
 
204
+ /** Filled dot previewing one state's primary color (colors[0]). */
205
+ function ColorDot(props) {
206
+ return createElement("span", { style: Object.assign({}, styles.dot, { background: props.color }) });
207
+ }
208
+
209
+ /** One swatch per parsed color; renders nothing while the text is unparseable. */
210
+ function ColorSwatches(props) {
211
+ var colors = parseColors(props.text);
212
+ if (colors === null) return null;
213
+ return createElement(
214
+ "span",
215
+ { style: styles.swatches, "aria-hidden": "true" },
216
+ colors.map(function (color) {
217
+ return createElement("span", { key: color, style: Object.assign({}, styles.swatch, { background: color }) });
218
+ })
219
+ );
220
+ }
221
+
207
222
  // ---------------------------------------------------------------------------
208
223
  // Form model
209
224
  // ---------------------------------------------------------------------------
@@ -270,6 +285,10 @@ window.__ModuleLoader__.load({
270
285
  var openState = useState(false);
271
286
  var open = openState[0];
272
287
  var setOpen = openState[1];
288
+ // Accordion: which per-state row is expanded (null = all collapsed).
289
+ var openNameState = useState(null);
290
+ var openName = openNameState[0];
291
+ var setOpenName = openNameState[1];
273
292
 
274
293
  if (!ready || value === null) return null;
275
294
 
@@ -300,6 +319,27 @@ window.__ModuleLoader__.load({
300
319
  return formatNumber(state.speed);
301
320
  };
302
321
 
322
+ var stateTitleKey = function (name) {
323
+ return "state" + name.charAt(0).toUpperCase() + name.slice(1);
324
+ };
325
+ /** One-line collapsed summary: "blink · #E5484D ⇄ #FACC15 · 400ms" (draft-aware). */
326
+ var stateSummary = function (name) {
327
+ var parts = [stateFieldValue(name, "effect")];
328
+ var colorsText = stateFieldValue(name, "colors").trim();
329
+ if (colorsText !== "") {
330
+ var parsed = parseColors(colorsText);
331
+ parts.push(parsed === null ? colorsText : parsed.join(" ⇄ "));
332
+ }
333
+ var speedText = stateFieldValue(name, "speed").trim();
334
+ if (speedText !== "") parts.push(speedText + "ms");
335
+ return parts.join(" · ");
336
+ };
337
+ /** Primary color for the row's dot; transparent while the draft is unparseable. */
338
+ var statePrimaryColor = function (name) {
339
+ var parsed = parseColors(stateFieldValue(name, "colors"));
340
+ return parsed === null ? "transparent" : parsed[0];
341
+ };
342
+
303
343
  var dirty = Object.keys(drafts).length > 0;
304
344
 
305
345
  var save = function () {
@@ -316,10 +356,16 @@ window.__ModuleLoader__.load({
316
356
  if (parsed.kind === "clear") { if (userHas(key)) ops.push({ op: "unset", field: key }); }
317
357
  else if (parsed.value !== value[key]) ops.push({ op: "set", field: key, value: parsed.value });
318
358
  }
319
- // Per-state visuals: rebuild the whole `states` object from the resolved
320
- // values with the drafts applied (deep-merged over the user layer).
359
+ // Per-state visuals: rebuild `states` as the user layer's current
360
+ // entries with the drafts applied. `scope.set` REPLACES the whole
361
+ // field (no deep merge), so untouched entries — overrides saved
362
+ // earlier for other states, plus any key not in STATE_NAMES — are
363
+ // carried through instead of being dropped.
321
364
  var statesDirty = false;
322
365
  var nextStates = {};
366
+ if (userLayer !== void 0 && userLayer !== null && typeof userLayer.states === "object" && userLayer.states !== null) {
367
+ nextStates = Object.assign({}, userLayer.states);
368
+ }
323
369
  for (i = 0; i < STATE_NAMES.length; i += 1) {
324
370
  var name = STATE_NAMES[i];
325
371
  var effKey = "states." + name + ".effect";
@@ -419,67 +465,90 @@ window.__ModuleLoader__.load({
419
465
  "div",
420
466
  { style: styles.body },
421
467
  !writable ? createElement("p", { style: styles.hint }, t("readOnly")) : null,
422
- createElement(NumberInput, {
468
+ createElement(ValueInput, {
423
469
  id: "plugin-config-icon-asking-hold",
424
470
  label: t("askingHoldMs"),
425
471
  hint: t("askingHoldMsHint"),
426
472
  invalidLabel: t("invalidNumber"),
427
473
  invalid: failed === "invalidNumber",
428
474
  disabled: disabled,
475
+ numeric: true,
429
476
  separated: true,
430
477
  value: fieldValue("askingHoldMs"),
431
478
  onChange: function (text) { edit("askingHoldMs", text); },
432
479
  }),
433
- createElement(NumberInput, {
480
+ createElement(ValueInput, {
434
481
  id: "plugin-config-icon-done-hold",
435
482
  label: t("doneHoldMs"),
436
483
  hint: t("doneHoldMsHint"),
437
484
  invalidLabel: t("invalidNumber"),
438
485
  invalid: failed === "invalidNumber",
439
486
  disabled: disabled,
487
+ numeric: true,
440
488
  separated: true,
441
489
  value: fieldValue("doneHoldMs"),
442
490
  onChange: function (text) { edit("doneHoldMs", text); },
443
491
  }),
444
- STATE_NAMES.map(function (name, index) {
445
- return createElement(
446
- "div",
447
- { key: name, style: styles.group },
448
- createElement("p", { style: styles.groupTitle }, t("stateLabel") + ": " + name),
449
- createElement(
450
- LabeledField,
451
- { label: t("effect"), hint: t("effectHint"), invalid: false, invalidLabel: "", separated: true },
452
- createElement(SelectInput, {
453
- id: "plugin-config-icon-" + name + "-effect",
454
- options: EFFECT_NAMES,
455
- disabled: disabled,
456
- value: stateFieldValue(name, "effect"),
457
- onChange: function (text) { edit("states." + name + ".effect", text); },
458
- })
459
- ),
460
- createElement(TextInput, {
461
- id: "plugin-config-icon-" + name + "-colors",
462
- label: t("colors"),
463
- hint: t("colorsHint"),
464
- invalidLabel: t("invalidColors"),
465
- invalid: failed === "invalidColors",
466
- disabled: disabled,
467
- value: stateFieldValue(name, "colors"),
468
- onChange: function (text) { edit("states." + name + ".colors", text); },
469
- }),
470
- createElement(NumberInput, {
471
- id: "plugin-config-icon-" + name + "-speed",
472
- label: t("speed"),
473
- hint: t("speedHint"),
474
- invalidLabel: t("invalidNumber"),
475
- invalid: failed === "invalidNumber",
476
- disabled: disabled,
477
- separated: true,
478
- value: stateFieldValue(name, "speed"),
479
- onChange: function (text) { edit("states." + name + ".speed", text); },
480
- })
481
- );
482
- }),
492
+ createElement(
493
+ "div",
494
+ { style: styles.statesBlock },
495
+ STATE_NAMES.map(function (name) {
496
+ var isOpen = openName === name;
497
+ return createElement(
498
+ primitives.DisclosureRow,
499
+ {
500
+ key: name,
501
+ className: "dsh-wii-state",
502
+ icon: createElement(ColorDot, { color: statePrimaryColor(name) }),
503
+ title: t(stateTitleKey(name)),
504
+ open: isOpen,
505
+ expandable: true,
506
+ expandOnRowClick: true,
507
+ onToggle: function () { setOpenName(isOpen ? null : name); },
508
+ collapsedContent: createElement("span", { style: styles.summary }, stateSummary(name)),
509
+ },
510
+ createElement(
511
+ "div",
512
+ { style: styles.statePanel },
513
+ createElement(
514
+ LabeledField,
515
+ { label: t("effect"), hint: t("effectHint"), invalid: false, invalidLabel: "", separated: false },
516
+ createElement(SelectInput, {
517
+ id: "plugin-config-icon-" + name + "-effect",
518
+ options: EFFECT_NAMES,
519
+ disabled: disabled,
520
+ value: stateFieldValue(name, "effect"),
521
+ onChange: function (text) { edit("states." + name + ".effect", text); },
522
+ })
523
+ ),
524
+ createElement(ValueInput, {
525
+ id: "plugin-config-icon-" + name + "-colors",
526
+ label: t("colors"),
527
+ hint: t("colorsHint"),
528
+ invalidLabel: t("invalidColors"),
529
+ invalid: failed === "invalidColors",
530
+ disabled: disabled,
531
+ separated: true,
532
+ value: stateFieldValue(name, "colors"),
533
+ trailing: createElement(ColorSwatches, { text: stateFieldValue(name, "colors") }),
534
+ onChange: function (text) { edit("states." + name + ".colors", text); },
535
+ }),
536
+ createElement(ValueInput, {
537
+ id: "plugin-config-icon-" + name + "-speed",
538
+ label: t("speed"),
539
+ hint: t("speedHint"),
540
+ invalidLabel: t("invalidNumber"),
541
+ invalid: failed === "invalidNumber",
542
+ disabled: disabled,
543
+ numeric: true,
544
+ separated: true,
545
+ value: stateFieldValue(name, "speed"),
546
+ onChange: function (text) { edit("states." + name + ".speed", text); },
547
+ })
548
+ )
549
+ );
550
+ })
551
+ ),
483
552
  createElement(
484
553
  "div",
485
554
  { style: styles.footer },
package/lib/index.js CHANGED
@@ -131,6 +131,7 @@ const INJECTED_SCRIPT = `
131
131
  var STATUS_PATH = "__STATUS_PATH__";
132
132
  var BASE_PATH = "__BASE_PATH__";
133
133
  var CFG = __CFG__; // { states: { idle:{effect,colors,speed}, running:…, asking:…, done:… } }
134
+ var CFG_JSON = null; // serialized states last seen; null = not synced yet
134
135
  var BASE = null; // base.svg text; __COLOR__ replaced per frame
135
136
  var RE = /__COLOR__/g;
136
137
  var DEF_SPEED = 1200; // per-state cycle fallback when speed is omitted
@@ -291,10 +292,24 @@ const INJECTED_SCRIPT = `
291
292
  };
292
293
  RAF = requestAnimationFrame(step);
293
294
  }
295
+ // Live config sync: the status response carries the current per-state
296
+ // visual config (the "states" field). When it differs from what this tab
297
+ // renders, swap it in and reset the animation phase so the next apply()
298
+ // repaints from scratch — a settings-card save takes effect within one
299
+ // poll tick, no tab reload needed. A response without "states" (older
300
+ // host) leaves the baked __CFG__ in place.
301
+ function syncCfg(j) {
302
+ if (!j || typeof j.states !== "object" || j.states === null) return;
303
+ var next = JSON.stringify(j.states);
304
+ if (next === CFG_JSON) return;
305
+ CFG_JSON = next;
306
+ CFG = { states: j.states };
307
+ PREV_STATE = null; // force a full repaint on the next apply
308
+ }
294
309
  function poll() {
295
310
  fetch(location.origin + STATUS_PATH, { cache: "no-store" })
296
311
  .then(function (r) { if (!r.ok) throw new Error("bad"); return r.json(); })
297
- .then(function (j) { apply(j.state || "idle"); })
312
+ .then(function (j) { syncCfg(j); apply(j.state || "idle"); })
298
313
  // Transient failures (host restarting, network blip) must NOT kill the
299
314
  // poll: restore the original icon and retry on the next tick. The SPA
300
315
  // reconnects in place, so the tab survives a host restart — the icon
@@ -572,6 +587,10 @@ export default {
572
587
  };
573
588
 
574
589
  // -- Routes --------------------------------------------------------------
590
+ // The status endpoint also echoes the current per-state visual config
591
+ // (`states`): the injected script syncs it on every poll, so a settings
592
+ // save changes the favicon within ~1 s without a tab reload. `no-store`
593
+ // keeps every poll reading the freshest config.
575
594
  ctx.effect(() => webServer.register({
576
595
  kind: "exact",
577
596
  path: cfg.statusPath,
@@ -579,7 +598,7 @@ export default {
579
598
  res.statusCode = 200;
580
599
  res.setHeader("Content-Type", "application/json; charset=utf-8");
581
600
  res.setHeader("Cache-Control", "no-store");
582
- res.end(JSON.stringify(aggregate()));
601
+ res.end(JSON.stringify({ ...aggregate(), states: cfg.states }));
583
602
  },
584
603
  }));
585
604
 
@@ -77,6 +77,12 @@ export interface DshWebIconIndicatorAggregate {
77
77
  state: DshWebIconStateName;
78
78
  /** Epoch millis when that aggregate state was first entered. */
79
79
  since: number;
80
+ /**
81
+ * Current per-state visual config, echoed by the status endpoint so the
82
+ * injected browser script can apply a settings save within ~1 s (no tab
83
+ * reload). Added in 0.2.x; older browser bundles ignore it.
84
+ */
85
+ states: Partial<Record<DshWebIconStateName, DshWebIconStateConfig>>;
80
86
  }
81
87
 
82
88
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-web-icon-indicator",
3
3
  "description": "Browser tab favicon reflects the DSH session state: idle / running / asking / done, recolored and animated from one base SVG in the browser (fully configurable colors + effects). · 浏览器标签页 favicon 实时反映 DSH 会话状态:待机 / 运行中 / 提问 / 完成,由单个 base.svg 在浏览器中动态上色与动画(颜色与特效完全可配置)。",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -24,7 +24,8 @@
24
24
  "@deepseek-ai/dsh-client-runtime",
25
25
  "@deepseek-ai/dsh-client-locale",
26
26
  "@deepseek-ai/dsh-client-ui-slots",
27
- "@deepseek-ai/dsh-client-ui-settings"
27
+ "@deepseek-ai/dsh-client-ui-settings",
28
+ "@deepseek-ai/dsh-client-ui-settings-plugins"
28
29
  ]
29
30
  }
30
31
  },