waknow/dsh-web-icon-indicator

waknow/dsh-web-icon-indicator

浏览器标签页 favicon 实时反映 DSH 会话状态(待机 / 运行中 / 提问 / 完成):单个 base.svg 在浏览器端上色与动画,颜色与特效均可在设置页配置。

UI Enhancementsdeepseekdeepseek-harnessdsh

dsh-web-icon-indicator

📖 中文文档 · English · 📝 Changelog · Releases · 🎨 Live demo

⚠️ DSH version support — requires DSH ≥ 0.1.2-rc.1. One bundle serves both settings generations: modern (≥ 0.1.7-alpha.1: exported Config schema + configForms + plugins.bundle.config) and legacy (≤ 0.1.6-alpha.1: settings.installSection + settingsScope + settings.plugin.item). Verified on DSH 0.1.5-rc.3 and DSH 0.1.7-alpha.1; on any older host the favicon still works even if the settings page is not reachable.

Browser tab favicon reflects the current DSH session state — idle / running / asking / done — so you can see at a glance whether a session needs your attention, even when the tab is in the background.

🎨 Live demo — https://waknow.github.io/dsh-web-icon-indicator/ · see the four states, the multi-agent counter and every effect rendered live in your browser, no install needed. The playground even drives the demo page's own tab favicon, exactly like the plugin does on a DSH page.

✨ What it does

  • Live session state on the tab favicon — the browser-tab icon mirrors idle / running / asking / done (aggregate priority: asking > running > done > idle), so background tabs tell you at a glance what your agents are doing — including ask_user_question prompts and approval / sandbox-escalation waits, which pin the icon to asking.
  • One SVG, recolored & animated in the browser — ships a single whale template (icons/base.svg); every state, color and frame is rendered client-side as a data:image/svg+xml URI. No per-color icon files.
  • Six built-in effects — static, blink, breath, rainbow, heartbeat, bounce — all driven by JavaScript, since favicons don't play SVG CSS animations.
  • Fully configurable, applied live — every state's color, effect and cycle speed, plus the asking/done hold timings, apply to the running tab within ~1 s — no reload, no restart.
  • Built-in settings UI, zero YAML — the Favicon indicator page edits the whole config with live color-swatch previews and persists it for you (modern hosts: the profile patch; legacy hosts: the profile settings.yaml — paths below).
  • Background-tab & restart-proof — animated states keep a wall-clock fallback while requestAnimationFrame is paused in hidden tabs, and the status poll self-heals across host restarts. Returning to a tab repaints immediately: a visibilitychange listener fires an instant status fetch, so a state that flipped while the tab was hidden (e.g. the done hold expiring) shows at once instead of waiting for the next — possibly throttled — poll tick. When the backend is stopped, the tab never loses its icon: the outage restores the shell's own favicon from an offline-safe data:-URI copy (or keeps the last painted frame), and the live icon returns on the first successful poll.
  • Active-agent count at a glance — while more than one agent is active (non-idle: asking / running / done), the favicon switches from the whale to a full-frame number block showing the live count (up to 99+), colored and animated exactly like the whale would be in that state; back to the whale when 0–1 agents are active. (Same visual language as the 满幅数字 channel in demo/badge.html.)

🛠 Configuration UI — how to get there

#Step
1Open the DSH Web GUI and go to Settings / 设置 → Plugins / 插件.
2On ≥ 0.1.7: open the dsh-web-icon-indicator bundle and click Configure / 配置 on its dsh-web-icon-indicator row. On ≤ 0.1.6-alpha.1: open Plugin config / 插件配置 in that tab.
3The Favicon indicator / 标签页图标指示器 page opens with the full form.
4Set Default icon color / 默认图标颜色 for the idle whale (its only knob — idle paints one color and never animates), then expand a state row (running / asking / done) to edit Effect / 特效, Colors / 颜色 (each swatch is a native color picker) and Cycle (ms) / 周期(毫秒) (shown only for animated states — static states have no cycle); use Asking hold / 提问驻留 and Done hold / 完成驻留 for the two timings.

Changes are saved through the settings transport into the profile patch and applied to the running tab within ~1 s — no reload, no restart. See Configure for the full key reference.

🐳 The Plugins page shows this plugin's own artwork — on DSH ≥ 0.1.7 the Plugins / 插件 page draws a 48 px icon on every bundle card (a 40 px one on every row) from package.json → icon. This repo declares "icon": "icons/plugin.svg", so the card shows the whale instead of the generic placeholder. The icon is read by the host (readPluginMeta → iconOf) and shipped to the browser as a base64 data: URL; a missing or malformed icon never blanks the card — it turns the plugin's meta.error on and the card shows a problem tag, so keep the path relative, inside the package, and ≤ 256 KiB. Adding icon needs no settings, no code, and no host restart — only a new install or publish; see Plugin artwork on the Plugins page.

🎬 Default configuration, visualized

The four default states, exactly as they appear in the browser tab (the asking whale really blinks):

Default states: idle dark whale, running yellow, asking red/yellow blinking, done green

StateDefault colorDefault effect
idle#1a1a1a — deep whale (replaceable with defaultColor)static
running#FACC15 — yellowstatic
asking#E5484D ⇄ #FACC15 — red/yellowblink (400 ms)
done#22A06B — greenstatic, stays doneHoldMs, then back to idle

Multi-agent, visualized

With several agents running at once, the favicon itself becomes the counter: while more than one agent is active (non-idle: asking / running / done, including the short done hold), the whale is replaced by a full-frame count block showing the live active count, filled with the aggregate state's color and driven by the same effect — so it keeps blinking / breathing / cycling exactly like the whale would. With 0–1 active agents it comes right back to the whale.

active=0 dark whale, active=1 yellow whale, active=2/3 yellow count blocks, active=3 asking red block (blinks), active=100 shows 99+

active (non-idle agents)Favicon
0dark idle whale
1that state's whale (running yellow, …)
2–99full-frame count block; digit height ≈31–52% of the icon (1 digit = 26, 2 = 20, 3+ = 15.5), readable at 16px and in pinned tabs
100+99+

State priority is unchanged, so asking still takes over with its red ⇄ yellow 400 ms blink (the count block blinks), done flashes its color for doneHoldMs, and the count refreshes live through the status poll (~1 s). Same visual language as the 满幅数字 channel of demo/badge.html.

✨ All effects, animated

Every preview below is the real whale path, animated the same way the plugin renders it (the previews are self-contained animated SVGs — they play right in your browser):

EffectWhat it doesPreview
staticA single colored frame, no motion — uses colors[0]static effect preview
blinkToggles colors[0] ⇄ colors[1] (a darker second color is derived if missing) over speedblink effect preview
breathPulsates smoothly between colors[0] and colors[1] (derived if missing) over speedbreath effect preview
rainbowUses colors[0] as the starting hue, then cycles the color wheel over speedrainbow effect preview
heartbeatScale pulses with a sharp lub-dub beat over speed — color is colors[0]heartbeat effect preview
bounceThe whale hops up and down over speed — color is colors[0]bounce effect preview

Want to tweak colors and watch the tab favicon change live? Open the self-contained demo (demo/dynamic-color.html) — pick a state + effect, edit colors, and the favicon updates in real time (no build, no dependencies).

Install

This is a standard DSH bundle plugin. Install it into the web profile (the GUI/TUI profiles pick it up automatically through the cordis patch layer).

From npm (recommended):

dsh plugin --profile web add dsh-web-icon-indicator@latest

From the Git source:

dsh plugin --profile web add github:waknow/dsh-web-icon-indicator

Or from a local directory / tarball:

dsh plugin --profile web add <path-or-tarball>

Or drop the directory into ~/.dsh/profiles/web/node_modules/<name>/ and ship a cordis.patch.yml that matches the one shipped here.

ℹ️ The official Plugins page reads the artwork from package.json.icon of the installed package, so it appears only after an install (or a publish) — a page that was already open keeps its last snapshot until you reload it. See Plugin artwork on the Plugins page.

Configure

All keys are optional; defaults shown. statusPath and iconPathPrefix are registration-time keys: set them in the composition entry only — they are baked into the route table and the injected script when the plugin mounts, so they are intentionally not part of the settings page's live form (they are Config fields, but not .volatile()).

KeyDefaultMeaning
iconsDir<package>/icons/Directory holding the single base.svg
statusPath/dsh-web-icon-status.jsonJSON status endpoint — registration-time (composition entry only)
iconPathPrefix/dsh-web-icon-indicatorURL prefix base.svg is served under — registration-time (composition entry only)
askingHoldMs3500Minimum visibility of the asking state
doneHoldMs5000Time the done state stays before falling back to idle
defaultColor(unset)Default icon color (the idle whale's primary) — tell multiple DSH instances apart. Warns when it is too close to another state's color
statessee belowPer-state visual config

The Plugins-page artwork is not a config key — it comes from package.json.icon (see Plugin artwork on the Plugins page). iconsDir only tells the favicon routes where base.svg lives.

Each entry in states is one object per state: { effect, colors[], speed? }:

config:
  states:
    idle:    { effect: static,    colors: ['#1a1a1a'] }
    running: { effect: static,    colors: ['#FACC15'] }
    asking:  { effect: blink,     colors: ['#E5484D', '#FACC15'], speed: 400 }
    done:    { effect: static,    colors: ['#22A06B'] }
  • effect — one of static | blink | breath | rainbow | heartbeat | bounce.
  • colors — an array of hex colors (#rgb / #rrggbb; a malformed entry is ignored, and the state's built-in colour applies when none survives). colors[0] is the primary. Multi-color effects read more entries: blink uses colors[0]⇄colors[1], breath breathes colors[0]⇄colors[1] (each derives a darker second color if omitted), rainbow uses only colors[0] as the starting hue.
  • speed — optional per-state cycle length in ms (also the blink toggle interval). Default 1200.

idle is special: its color is the defaultColor key, and the settings card offers no per-state entry for it (one color, no animation, no cycle). A states.idle entry is still honored when it arrives from the composition entry or the settings document's user layer — backward compatibility only; it is simply not editable from the page.

Entries are shallow-merged over the defaults, so you can override only a few states. Example:

- id: dsh-web-icon-indicator
  name: 'dsh-web-icon-indicator'
  config:
    states:
      running: { effect: breath,    colors: ['#FF9900', '#FFD9A0'], speed: 900 }
      asking:  { effect: rainbow,   colors: ['#FF0000'] }
      done:    { effect: heartbeat, colors: ['#2ECC71'] }

Tell multiple instances apart (defaultColor)

Running several DSH instances at once (different projects, profiles or ports)? Give each one its own default icon color and the browser tabs become immediately distinguishable — no need to touch the per-state palette:

- id: dsh-web-icon-indicator
  name: 'dsh-web-icon-indicator'
  config:
    defaultColor: '#5B8DEF'
  • defaultColor is the idle whale's primary color. It is folded into states.idle.colors[0], so the idle state keeps its effect and any second color you configured; the other states keep their signal colors. Unset (the default) means "the idle state's own color", i.e. today's behavior.
  • It is a per-DSH-instance setting, not per browser tab: every tab of one instance shares it, while another instance (its own profile, e.g. dsh web --port 3081) can use a different color.
  • Reset removes your overrides back to the composition entry. When the color comes from that entry (the base layer, which an unset cannot reach), the card writes the idle state's own color instead — so "Reset to defaults" really returns the icon to the plain whale color, and the entry's value stays reachable through the clear-override control.
  • Similarity warning. When the default color is perceptually too close to another state's color you get a warning — live in the settings card, in the host log, and as warnings on the status endpoint — but the value is still applied (the warning never blocks saving). Distance is CIE76 ΔE in CIELAB: ΔE < 25 warns, ΔE < 12 is reported as nearly identical (ΔE 2.3 is the just-noticeable difference). Every fill a state actually paints is considered: the asking blink covers both of its colors, breath its interpolation, and a rainbow state is flagged for any chromatic default because it sweeps every hue. The shipped running and asking colors deliberately share #FACC15, so states are never compared with each other — only the default color against them. A malformed value is ignored (and reported) rather than painted.

Settings page (DSH ≥ 0.1.2-rc.1, both settings generations)

The plugin exports the whole config surface above as its Cordis Config schema (a schemastery schema in lib/index.js) and registers the same schema through the legacy settings service when the running host still exposes it — the two paths are feature-detected at mount, so one bundle serves both. The namespace differs by generation: ≥ 0.1.7 keys every live form by profile entry id, so it is dsh-web-icon-indicator (the row id the bundle patch declares); ≤ 0.1.6-alpha.1 uses the plugin-chosen web-icon-indicator, the same string 0.5.x used, so an existing section keeps resolving:

  • Web GUI: open 设置 → 插件 and click the dsh-web-icon-indicator card — the settings form opens directly on the plugin's page (the same place the official plugins put theirs) — including on 0.1.6-alpha.2, which still binds through the legacy settingsScope but already renders the bundle slot. The Favicon indicator page edits the same keys: asking/done hold, the default icon color (with a live palette preview and the similarity warning), and the per-state effect / colors / cycle for running / asking / done. Each of those states is a collapsible row whose header shows one color chip per state — split in two for a multi-color state, so asking shows red | yellow at a glance — plus a one-line summary of the effect and cycle (Blink · 400ms). Clicking a chip opens the native color picker, which is where the hex value is shown/typed; the card prints no colour codes itself, except in the similarity warning, which names the offending hex to be actionable (e.g. … Running color #FACC15 (ΔE 0)). The expanded row lists one chip per color the chosen effect actually uses: blink / breath get a + chip while there is still room for a second color (it opens on the darker shade the browser would derive anyway, and never appends a color the state already has), every color after the first can be removed, and a single-color effect shows — and saves — only one chip, so an unused second color can never linger invisibly. rainbow carries no color list: its field and its chip show the hue wheel, next to an optional chip for the starting hue (the stored colors[0]). idle deliberately gets no row — it paints one color and never animates, so the default-color field is its whole configuration. Everything is staged and saved through the settings transport.
  • Persistence: on ≥ 0.1.7 values land in the profile patch (~/.dsh/profiles/<profile>/cordis.patch.yml) under the dsh-web-icon-indicator row's config: block; on ≤ 0.1.6-alpha.1 they land in ~/.dsh/settings.yaml as a web-icon-indicator: section. In both cases the composition entry stays the base layer; resolution order is schema defaults → composition entry → user layer.
  • No server restart, no tab reload for settings-page saves. On ≥ 0.1.7 a live write is committed into the running plugin's config references (loader/volatile-update — the loader does not restart the plugin); on the legacy line the service calls the registered onChange hook. Either way askingHoldMs / doneHoldMs apply host-side immediately and per-state visual config (effect / colors / cycle) is synced into the running tab through the status poll within ~1 s. Only code-level default changes in lib/index.js need a tab reload (or a DSH web rebuild).
  • Route paths are not live settings. statusPath / iconPathPrefix are validated by Config but deliberately not .volatile(), so they are absent from the live form: they are baked into the route table and the injected script at registration time, and honoring an edit there would point the browser at a path the server never serves. They are composition-entry only (see the table above); changing them re-applies the plugin.
  • The live surface therefore covers askingHoldMs, doneHoldMs, iconsDir, defaultColor and states. iconsDir has no schema default, so it is omitted from the resolved form until a user sets it.
  • The browser half is a hand-written lib/client.js (ModuleLoader factory format — no build step, no runtime deps beyond the shell's react). It registers on the slot the running host declares — plugins.bundle.config on ≥ 0.1.7 (so the Plugins list's card opens straight onto the form), else plugins.row.config; both gated on the served namespace — plus the legacy settings.plugin.item — and resolves whichever settings provider the host exposes (configForms or settingsScope) at render time, so neither is a hard dependency of the module. The DSH client scanner picks a new dsh.client declaration up on the next profile start.
  • Deployments without a settings service are unaffected: the plugin keeps running on the composition entry + schema defaults it was mounted with.

Plugin artwork on the Plugins page

DSH ≥ 0.1.7 renders a per-plugin icon on the official Plugins page: 48 px on each bundle card, 40 px on each row (PackageArtwork in @deepseek-ai/dsh-client-ui-plugin-manager, which falls back to a generic placeholder when a package declares none). The artwork comes straight from the package manifest, so shipping one is pure metadata — no code, no settings, no host restart:

// package.json
{
  "name": "dsh-web-icon-indicator",
  "icon": "icons/plugin.svg"   // relative to package.json, and inside the package
}

The host reads it before evaluating any plugin code (readPluginMeta → iconOf in @deepseek-ai/dsh-app-boot):

RuleValue
Fieldtop-level package.json.icon (a sibling of name/version — not under dsh)
Path formrelative to the manifest's directory; absolute paths, Windows drive letters (C:\…) and URLs are rejected
Containmentmust stay inside the manifest directory after realpath resolution (.. escapes are rejected)
Formats.svg → image/svg+xml, .png → image/png, .jpg / .jpeg → image/jpeg, .webp → image/webp
Size≤ 256 KiB (checked both on stat and on the bytes actually read)
Deliveryread as bytes and shipped to the browser as a base64 data: URL on the wire (PluginLocalizedMeta.icon)

⚠️ A bad icon is worse than no icon. Any violation is a metadata error, not a silent fallback: the reader returns meta.error, the host surfaces it, and the card shows a problem tag for the plugin. Keep the file in the package (files ships icons/) and make sure it is standalone — a raw icons/base.svg would be invalid, because its #p { fill: __COLOR__ } placeholder is not a colour. This repo therefore ships the pre-filled icons/plugin.svg (base.svg with __COLOR__ → #1a1a1a) and asserts the whole contract in test/verify.js (F62–F65).

Because the icon is read from the installed package, a local edit takes effect only after reinstalling the plugin (dsh plugin --profile web add <path>) or publishing a new version.

How it works