dsh-status-rotator
Give DSH's running status your own voice: custom phrases, typewriter output, day/night gradients, danmaku and an optional animated whale tail. Ships 1483 phrases, 14 theme packs; choose which packs to enable.
English | 中文 · Effects · Install · Reference · Changelog
A DeepSeek Harness (DSH) client plugin that replaces Deep diving... / 深度求索中... with editable phrases while preserving the host's elapsed-time clock.
Effect preview
Status text and light/dark gradients
Custom phrases type out and rotate beside the official whale-tail sway, with light and dark gradient palettes.
Phrase rotation · Typewriter · Light/dark gradients · Official whale-tail sway. Captured from the actual plugin in a minimal DSH host, using sample phrases and an enlarged status row. Static preview.
Whale-tail animations
Official sway, flipping tail, sway and twist rendered by the plugin in a two-by-two animation comparison.
Reading left to right, top to bottom: Official sway → Flipping tail → Sway → Twist. Official sway uses its recommended 4-second cycle; the other three use 0.5 cycles/s. The icon containers stay fixed while the contours deform. Static comparison.
These actions ship in v0.35.0. Under Appearance → Whale tail, enable both Keep the whale tail and Tail motion, then choose the flipping tail, 24-frame sway, 36-frame twist, 150-frame official original sway, or random switching. Existing configurations without animation keep the flipping tail.
| Choice | Behavior |
|---|
| Official sway | 150-frame action, recommended speed one cycle every 4 seconds |
| Flipping tail / Sway / Twist | Fixed speed or estimated tok/s; default level 3 tops out at 1.5 cycles/s |
| Random switching | Select an action pool shared by timed switching and optional tool-call triggers |
Speed presets have 2–5 levels; custom tok/s maximums range from 1–10 cycles/s. The official action keeps its recommended speed unless you disable that option.
Speed settings, action pools and tool-call triggers.
Appearance themes and danmaku
Five built-in appearance themes rendered by the plugin: Classic, Neon, Terminal, Candy and Glitch.
Classic / Neon / Terminal / Candy / Glitch set the font, glow, animation and gradient together. Pick a theme, then adjust its individual options. Theme parameters.
Real plugin danmaku crossing a dark preview surface above the running status row.
Danmaku supports scrolling, top and bottom placement. Optional hover-pause, click-to-copy, phase colors and adaptive density are available in Settings. This is a captured frame with enlarged text and higher opacity for readability. Danmaku parameters.
Installation
Install from npm through the Desktop plugin manager, or use dsh plugin add for Web. Restart the corresponding host after the first install.
Desktop npm walkthrough
DSH Desktop can install directly from npm without cloning this repository. The screenshots below show DSH Desktop 0.2.0-rc.2 with a Chinese interface; layouts may differ between host versions.
-
Open DSH Desktop, select Plugins (插件) in the sidebar, then click Add plugin (添加插件) at the top right.
-
Enter this in the Package name or address (包名或地址) field:
dsh-status-rotator
Choose an Install source (安装源) suitable for your network. The screenshot uses the Mainland China mirror. Click Install (安装); Desktop downloads the npm package and registers the plugin.
Add plugin dialog with dsh-status-rotator entered, the Mainland China mirror selected and the Install button available.
-
Wait for installation to finish. Find dsh-status-rotator under Installed (已安装) and confirm that its enable switch is on. Follow any restart prompt; if the settings page has not appeared, fully exit Desktop, including its tray process, and reopen it.
-
Open the account menu at the bottom left, then Settings → Status Texts (设置 → 状态文案). Choose packs or edit phrases. For tail motion, enable Keep the whale tail and Tail motion under Appearance → Whale tail. Settings save automatically; start a new turn to see the status line.
Install the npm package from the command line
dsh plugin --profile desktop add dsh-status-rotator
Restart DSH Desktop afterward. Desktop uses the desktop profile; if your terminal cannot find dsh, use the Desktop installation dialog above.
Quick start
For Web, run:
dsh plugin --profile web add dsh-status-rotator
Restart dsh web, then open Settings → Status Texts and start a turn. For Desktop, use the npm walkthrough above or its command-line alternative. Later settings changes save and apply automatically; restart the corresponding host after upgrading plugin code.
The whale tail and tail motion start off; enable them under Appearance → Whale tail. Danmaku starts on and can be disabled under Appearance → Danmaku.
Other installation methods and first-run behavior
Option A: dsh plugin add (recommended)
The plugin's package.json declares a dsh.bundle.patch manifest, so it is recognized automatically after install — no extra flags needed. The command syntax is dsh plugin --profile <name> add <package> (e.g. --profile web):
- From npm (easiest):
dsh plugin --profile web add dsh-status-rotator ← always installs the latest release
- For DSH Desktop:
dsh plugin --profile desktop add dsh-status-rotator (the desktop build's profile is desktop)
- From a clone:
dsh plugin --profile web add ./dsh-status-rotator
- From a release package: download
dsh-status-rotator-<version>.zip from the Release page (it contains a ready-to-use plugin directory with config.json — not an npm tarball), unzip it, then dsh plugin --profile web add /path/to/dsh-status-rotator.
Manual installation
Option B: manual install
-
Put this project directory under your profile's node_modules (default C:\Users\<you>\.dsh\profiles\node_modules\dsh-status-rotator\);
-
Insert the following into the profile's cordis.patch.yml:
- insert:
- id: status-rotator
name: dsh-status-rotator
-
Run node gen-config.cjs to initialize the local config.json (copied from config.example.json);
-
Restart dsh web and hard-refresh the browser with Ctrl+F5.
First run
On first start the plugin serves, in order: your saved settings ($DSH_HOME/status-rotator/config.json) → the package config.json → config.example.json (what an npm install has: all 1483 default phrases live inside it, see Phrase Bank). Two more layers join in: the auto-updated bank (every 6 hours, see Auto-updating the bank) and the optional external bank ($DSH_HOME/status-rotator/phrases.json, highest priority, see Hot-reloadable external bank). Edit files directly (hot-reloaded while a page is open) or use the Settings → Status Texts page of DSH.
Feature Overview
| What to customize | What you can do |
|---|
| Status text | Rotate by thinking, running and long-task phases; typewriter output, weighted picks and an anti-repeat shuffle bag |
| Phrase content | Toggle and edit packs; use conditions, live placeholders and Chinese/English text |
| Appearance | Light/dark gradients, appearance themes, danmaku with optional hover-pause and click-to-copy, and whale-tail motion |
| Daily workflow | Schedule presets by weekday and time; optionally rotate the browser tab title |
| Saving and updates | Store settings outside the package; hot-apply config changes and report conflicting settings-page writes |
| Plugin integration | Register packs, placeholders and dynamic phrases, or send danmaku through ctx.statusRotator |
Settings Page
Open Settings → Status Texts. Content, Appearance, Behavior and Automation organize the controls; changes save automatically.
Controls and save behavior
Open Settings in the bottom-left of DSH and a new Status Texts page appears in the navigation. The page is split into four tabs and follows the official plugin settings-page spec (760px column, the same tab / field / input language):
Content
- Edit target — one selector covering the base library, any preset (
preset:<id>) and any phrase pack (pack:<id>); saving writes to that target. A target that no longer exists (preset or pack deleted) falls back to the base library;
- 中文 / English tabs, each with three text boxes for
thinking / running / long, one phrase per line, blank lines ignored; a line text | weight sets that phrase's weight; each phase shows its phrase count;
- Pack toggles — ten theme packs ship enabled; community, ads and both star packs ship disabled;
- Presets — pick a preset, New to create one, edit its name inline (stored per editing language), Delete to remove it (schedule rules referencing it go with it), and "Set active" to write
activePreset.
Appearance
- Font weight, shared by the status line and danmaku;
- Rainbow gradient: enable toggle, palette mode (follow the interface light/dark, or force day/night), flow direction (right-to-left / left-to-right), separate day + night color sequences, flow speed;
- Danmaku: enable toggle, spawn interval, cross duration, random font-size range, rainbow mode + palette, opacity, max concurrent bullets, layer z-index and phrase scope.
Behavior
- Rotation interval, typewriter speed, long-task threshold, auto-reload interval, placeholder refresh interval, weighted-random toggle.
Automation
- Schedule editor: add/remove weekday + time-window rules that switch presets automatically; the currently effective preset (schedule included) is shown live.
Across the page:
- Changes save themselves: toggles and selects write immediately, text/number fields 400ms after you stop typing (a preset rename on blur) — no save button, and only a failed write turns the toolbar red;
- Every write PUTs the full JSON to
/plugins/dsh-status-rotator/config.json; the node half validates and , and open pages hot-apply it — invalid content returns 400 and shows an error instead of corrupting the file;
Whale-tail settings
Speed settings, random switching and tool-call triggers
Recommended official speed. officialRecommendedSpeed defaults to true. Official sway uses 0.25 cycles/s (one cycle every 4 seconds) in fixed, tok/s, random and tool-triggered playback. Other actions restore the user’s speed automatically. Turn this switch off to apply the common speed settings to official sway too.
Speed levels. Levels 2–5 are speed presets with maximums of 1.0 / 1.5 / 2.0 / 2.5 cycles/s: higher levels move faster at high output rates. For other actions, every level has a minimum of 1 cycle/s; level 3 uses 1.00 / 1.25 / 1.50. Selecting a level restores its maximum. Editing a level’s maximum shows Custom rather than mislabeling it as a preset. Continuous mode retains its own maximum. Boundaries have a deadband of about 4.8 tok/s on each side. Explicit maximums in old configurations remain effective and show Custom when they do not match a preset.
Random mode requests a switch every 4–8 seconds, choosing a different action only from randomActions. It waits for the current loop junction and uses six contour bridge frames. A single selected action keeps playing without redundant transitions. Tool-call triggers use the same pool. All four actions share speed and gradient settings. Hidden pages pause playback; turn end, disabling or unloading releases contours and listeners.
Speed changes preserve animation progress to avoid jumping frames. Rebuilt host icons recover the current action, speed, and gradient; random mode also retains its switching schedule. These ideas were inspired by dsh-whale-sway; see the adaptation notes.
Rebuilt icons restore their within-cycle progress. A six-frame bridge already in flight also resumes its original target and progress. Temporary icon gaps retain a playback snapshot until the row is released, without holding the old DOM nodes.
Tool-call triggers: enable “Randomly switch on tool calls” in the same settings group. It defaults to off with a 35% chance, configurable from 0–100%. Each new tool/call in the current session is checked once; history, paging, repeated notifications, and streamed arguments do not trigger it. Hits switch to a different action at the junction through six bridge frames; consecutive hits share one pending request. This works with fixed actions and timed random mode. A tool-triggered replacement of a fixed action remains until another trigger, an action selection change, or disabling this option. Disabling it restores the selected action at a junction. Hosts without the event source simply skip this feature.
How It Works
Phase timing, host integration and text sources
Phase Awareness
Phrases are split into three groups based on turn progress (determined by whether a clock has appeared in the status element and its reading):
| Phase | Trigger | Default duration |
|---|
thinking | Turn just started, no clock | 0 ~ 15s |
running | Clock visible, under the limit | 15s ~ longAfterMs |
long | Clock past longAfterMs | ≥ 60s |
Phase changes swap the phrase immediately without waiting for the rotation interval. If a phase has no phrase group, it falls back automatically (running → thinking → any non-empty group).
Zero-Intrusion Targeting
The status label is located by role="status" + aria-live="polite" (dsh ≤0.1.6) or button[data-turn-process] (0.1.7+), so code snippets in the chat history and other aria-live regions are never touched. On 0.1.7+ the plugin does exactly three things: insert its own line in the composer seat, hide the header copy, and read the header label for the duration — the host clock is only read, while phase and elapsed come from the session snapshot.
Status line text source (label source)
config.labelSource decides what the status line says; both host generations honour it:
| Value | Status line text | When to use it |
|---|
"phrases" (default) | one phrase from the bank, rotating per phase | the plugin's normal behaviour |
"host" | the host text only: Deep diving... / 深度求索中 | when you want the pure 0.1.6 look with no meme phrases |
- Never a blank line: when the bank is empty (no
config.json, or a preset cleared the texts), "phrases" mode falls back to the host text instead of leaving an empty row with only the clock;
"host" copies the 0.1.6 look as well: the plugin line matches .turnStatus property for property (weight 500, height: calc(26px + …), , same shimmer and fallback) and (13px, tabular-nums, 8px gap, weight 400), with the clock on the old schedule () and the text written in one go rather than typed; old hosts (≤0.1.6) are not touched at all in mode;
Phrase Bank
The default bank ships 1483 phrases, split into 14 theme packs (the core phrases table is empty — everything lives in packs). Ten packs are enabled by default; community, ads and the two star packs ship disabled — turn them on from Settings → Status Texts → Phrase packs:
| Pack | zh | en | Total | Default |
|---|
deepseek DeepSeek 专场 | 107 | 111 | 218 | on |
coding 写代码日常 | 87 | 81 | 168 | on |
daily 日常 | 77 | 64 | 141 | on |
internet-memes 网络梗 | 57 | 34 | 91 | on |
sysadmin 系统管理 | 41 | 38 | 79 | on |
slacking 摸鱼 | 40 | 33 | 73 | on |
math-physics 数学与物理 | 35 | 22 | 57 | on |
western-ai 西方 AI 圈 | 20 | 22 | 42 | on |
reverse-proxy 反代 | 20 | 21 | 41 | on |
china-ai 中国 AI 圈 | 22 | 14 | 36 | on |
community Community | 88 | 0 | 88 | off |
ads Ads | 3 | 3 | 6 | off |
star-ask 求 star | 11 | 12 | 23 | off |
star-route 星标者路由 | 210 | 210 | 420 | off |
| total | 818 | 665 | 1483 | 946 on / 537 off |
- Most entries are zh/en mirrored pairs; recent community submissions are often zh-only — choose zh + en (both) in the submission form to get each phrase in both languages;
- 5 weighted showcase entries (see Weighted Random) — most phrases are plain weight-1 strings;
- The bank grows through the community phrase-submission form: validated and merged submissions are credited in CONTRIBUTORS.md;
- The numbers are computed from
config.example.json by node scripts/sync-bank-counts.cjs (the submission bot and the star-pack refresh call it automatically; run it once after editing the bank by hand); run node scripts/check-bank-memes.mjs locally to audit the current bank (duplicates, lengths, ellipsis, series share).
The star packs (off by default) — two separate packs, so you can take one without the other:
| Pack | What it is |
|---|
star-ask 求 star | pure star-ask phrases, e.g. 正在向你讨一个 star… / Begging for a star… |
star-route 星标者路由 | one phrase per current stargazer — 正在路由 <login> 写代码… / Routing <login> to write code…, so the rotation literally routes every star-giver to work |
They ship disabled because begging is a matter of taste, not because they are broken: flip them on in Settings → Status Texts → Phrase packs. The Star packs workflow refreshes the stargazer list (weekly, when its own files change, or on demand) with the repo GITHUB_TOKEN, landing through a bot PR that is merged automatically once Test is green; the job is skipped in forks (their token cannot read stargazers of this repository) and STAR_TOKEN overrides it. Locally: node scripts/update-star-pack.cjs --token <pat>.
Phrase Packs
Pack JSON and merge rules
The bank is composable from named packs layered on top of the core phrases table:
{
"packs": [
{ "id": "community",
"label": { "zh": "社区投稿", "en": "Community" },
"phrases": { "zh": { "running": ["正在试用词库包…"] } } }
],
"enabledPacks": ["community"] // absent = all packs enabled; [] = core bank only
}
- Enabled packs merge into the effective bank in order, deduped by text — an entry already present in the core bank (or an earlier pack) is skipped, keeping its weight;
enabledPacks absent/null = all packs on; [] = core bank only. Unknown ids in the list are ignored;
- Packs support the exact same entries as the core bank (strings or
{text, weight}, per-phase groups, placeholders);
- The settings page shows every pack with a per-pack enable toggle and a pack editor target: pick a pack and the phrase library editor reads/writes that pack's phrases;
- The default config ships 14 packs (
deepseek / western-ai / china-ai / coding / reverse-proxy / sysadmin / math-physics / slacking / internet-memes / daily / community / ads / star-ask / star-route); enabledPacks pins the ten default-enabled ids.
- The phrase-submission form has a 目标词库包 picker: submissions append to the selected pack, with
community as the default. ads accepts attributed ads; star-route is generated from stargazers and is not a submission target.
- Old configs without packs keep working untouched.
Weighted Random
Entries are picked by weight. Write a phrase as "text | 3" (or { "text": "text", "weight": 3 }) for weight 3; no weight = 1, capped at 1000, invalid values count as 1. weightedRandom: false goes back to fully uniform. Five showcase entries in the bank use weights.
Anti-repeat (shuffle bag)
Since v0.31.0 the status line remembers what it just said per language + phase, with a shuffle bag instead of the old "avoid the previous phrase" rule:
- A phrase is never repeated until the bag is empty — with 40 phrases in a phase you get 40 different lines in a row, not a lottery that can hand you the same one twice;
- When the bag is refilled, the last
recentLimit phrases are held back, so a cycle boundary cannot repeat either;
- Weights still apply inside the bag (a weight-9 phrase tends to come up early in each cycle);
persist: true keeps the memory in browser storage, so a page reload does not immediately show the same line again.
"antiRepeat": { "recentLimit": 3, "persist": false }
recentLimit is 0–50 (0 = bag only, no cross-cycle memory). Everything is editable from Settings → Status Texts → Behavior → Anti-repeat.
Conditional phrases (when) and rarity
Conditions, rarity and editor syntax
A phrase can be restricted to the state it actually describes, so it appears exactly when it is true instead of competing with the other 1200 lines. Add a when object (or the | when:… suffix in the settings editor):
"phrases": { "zh": { "running": [
"正在写代码…",
{ "text": "正在敲命令…", "weight": 2, "when": { "tool": "bash" } },
{ "text": "正在满世界翻…", "when": { "tool": "web_search" } },
{ "text": "又失败了,再试一次…", "when": { "retry": true } },
{ "text": "等你点头…", "when": { "pending": true } },
{ "text": "夜深了…", "when": { "hour": [22, 6] } },
{ "text": "初次见面…", "when": { "firstTurn": true } },
{ "text": "传说级的一句…", "rarity": 0.01 }
] } }
| Condition | Type | Meaning |
|---|
tool | string / string[] / "*" | a tool with that name is running ("*" = any tool) |
retry | boolean | a retry is in flight |
pending | boolean / number | dsh is waiting for your answer (number = at least that many) |
phase | string / string[] | thinking / running / long / idle |
hour | [from, to] | local hour window, may cross midnight ([22, 6]) |
firstTurn | boolean | the first turn of this session |
- All listed conditions must hold (AND).
{ "tool": "bash", "phase": "long" } needs both;
- A matching conditional phrase wins the draw: if any phrase's conditions hold, the pick is made from those — otherwise a
bash phrase would be buried under a thousand unconditional ones. When nothing matches, the unconditional phrases are used as usual, so the line is never empty;
rarity (0, 1] is an easter egg: the phrase only takes part in that fraction of draws. 0.01 = 1%;
- Conditions are judged from the live engine (
{tools} / retry / {pending} / phase); on a host without those APIs the conditional phrases simply never appear and the unconditional ones carry on.
Template Placeholders
Available placeholders and examples
Any phrase (and any title template) may contain placeholders, replaced at render time:
| Placeholder | Meaning | Example |
|---|
{elapsed} | elapsed time of the current turn, localized like the clock | 正在写代码 1分02秒… |
{phase} | phase id: thinking / running / long / idle | running |
{phaseLabel} | localized short label of the phase | 运行中 |
{model} | model of the current session (live engine, — when unknown) | deepseek-chat |
{provider} | provider route of the current session (live engine) | deepseek |
{tps} | streaming tokens/s estimate (live engine) | 12 |
{pending} | interactions waiting for an answer — approvals and questions share this one counter (live engine) | 1 |
{tools} | running tool names joined with + (live engine) | bash+web_search |
{running} | run / idle (live engine) | run |
{retry} | retry attempt in the current step (live engine; empty when none) | 3 |
{retryMax} | the retry policy's cap (may be empty on older hosts / always mode) | 5 |
{retryProvider} | provider that triggered the retry (provider-neutral, passed through verbatim) | deepseek-official |
{retryCode} | short failure code (safe token ≤32 chars; URLs, paths and raw messages are never rendered) | sampling_error |
Rainbow Gradient
Palettes and gradient configuration
Status text is drawn with an animated rainbow gradient by default (text only, not the clock). Since v0.22.0 there are two palettes — night (dark) and day (light) — following the interface theme (mode: "auto"; "day" / "night" forces one) and re-coloring live; direction is "rtl" (default) or "ltr" to match the typewriter (issue #41). Disable or recolor it in the config:
"gradient": {
"enabled": false, // false to disable; true for default colors
"mode": "auto", // auto follows the interface light/dark theme; day / night forces one
"direction": "rtl", // rtl right-to-left (default); ltr left-to-right (matches the typewriter)
"colors": ["#ff5f6d", "#00ff88", "#4da6ff"], // night (dark theme) color sequence (at least 2, first/last cycle)
"dayColors": ["#d92b4b", "#0e7490", "#6d28d9"], // day (light theme) color sequence (at least 2, first/last cycle)
"speed": 4 // animation speed (seconds per cycle)
}
Existing configs that only set colors keep using it in both themes (nothing changes on upgrade); add dayColors to get a separate light-theme palette.
Appearance themes
Appearance options and presets
Everything that used to be limited to the gradient and the font weight now has a full set of ingredients, plus a theme gallery that sets them all in one click (Settings → Status Texts → Appearance → Appearance theme).
"appearance": {
"fontFamily": "", // empty = follow the interface; letters/digits/spaces/commas/quotes/hyphens only
"fontSize": 0, // px; 0 = follow the host; other values are clamped to 8-96 px
"glow": false, // soft halo around the text
"glowColor": "", // empty = first colour of the gradient palette
"animation": "none", // none | breathe | glitch
"spinner": "none" // none | ring | bar
}
- Every default means "change nothing" — a config without this block renders exactly as before;
- The theme gallery ships five packs (
classic / neon / terminal / candy / glitch); picking one writes both the appearance and the gradient palette, and every field stays editable afterwards;
animation and spinner are attached to the plugin's own text span as CSS classes, so the host's own text and clock are never touched; breathe and glitch are dropped under prefers-reduced-motion;
- The activity indicator shows "work is happening", not a percentage - the host exposes no turn progress;
fontFamily is written into CSS, so it is validated against a strict whitelist (letters, digits, spaces, commas, quotes, hyphens) and anything else is rejected rather than escaped.
Danmaku
Danmaku parameters, interaction and dialog behavior
Optional: every phrase can also spawn as video-site-style bullet-screen comments flying from right to left across the page (by default behind the UI — the layer is squeezed between the app background and the chat content, visible in the gaps):
"danmaku": {
"enabled": true,
"intervalMs": 2500, // spawn interval (ms); smaller = more of a flood
"speedMs": 18000, // time to cross the screen, right → left (ms); larger = slower
"fontSizeMin": 14, // min random font size (px)
"fontSizeMax": 30, // max random font size (px)
"rainbow": true, // rainbow mode: each bullet picks a random color from `colors`
"colors": ["#ff5f6d", "#00ff88", "#4da6ff"], // palette (at least 1)
"color": "#ffffff", // solid color used when rainbow = false
"opacity": 0.3, // global opacity (0.05 ~ 1); each bullet jitters between 75% and 100% of it
"maxCount": 12, // max concurrent bullets on screen
"zIndex": -1, // negative = behind the UI (default), non-negative = above the UI
"scope": "all", // "all" = every phrase of the current language; "phase" = current phase only (with fallback)
"marginTop": 16, // top padding of the bullet band (px)
"marginBottom": 160, // bottom padding (px), keeps the input area clear
// ── new in v0.19: top / bottom (bilibili-style) danmaku ──
"types": { // per-type switch + relative weight; scroll = the original type
"scroll": { "enabled": true, "weight": 2 },
"top": { "enabled": true, "weight": 1 },
"bottom": { "enabled": true, "weight": 1 }
},
"mode": "scroll", // optional: force ONE type for every bullet (scroll/top/bottom, or 1/4/5); omit = weighted
"fixed": { // top/bottom style — the single place to change them all
"fontSize": 25, // px
"color": "#ffffff", // solid colour used when rainbow = false
"shadow": "1px 0 1px rgba(0,0,0,.85),-1px 0 1px rgba(0,0,0,.85),0 1px 1px rgba(0,0,0,.85),0 -1px 1px rgba(0,0,0,.85)",
"marginTop": 16, // distance from the top edge of the play area (px)
"marginBottom": 160, // distance from the bottom edge (px)
"gap": 4, // stacking gap between bullets (px)
"durationMs": 4500, // how long one bullet stays on screen (ms)
"maxCount": 3, // max bullets of the SAME type at once
"zIndex": 10, // front layer: 10 sits above the chat, below the shell overlay (20)
"reserveBands": true, // scrolling bullets keep out of the top/bottom lanes (no overlapping text)
"anchorBottomToHost": true, // bottom bullets sit above the input area (its status line), not merely marginBottom away
"overflow": "drop" // full → drop this spawn (same strategy as scrolling danmaku)
}
}
Browser Tab Title
Title templates and ownership
Optional and off by default. When on, the browser tab title rotates through your templates while a turn is running:
"title": {
"enabled": true,
"templates": ["⏳ {phaseLabel} {elapsed}", "🤔 {phaseLabel}… {elapsed}"], // rotated every intervalMs
"idleTemplate": "💤 dsh 空闲", // "" = hand the title back to the host when idle
"intervalMs": 8000
}
Templates support the same placeholders as phrases. When no turn is active the title shows idleTemplate; with idleTemplate: "" (or enabled: false) the plugin hands the title back to the host. title: false disables it entirely.
Editable from the settings page (since v0.27.0): DSH → Settings → Status Texts → Behavior has a Tab title group — an on/off switch, the templates (one per line), the idle title and the rotation interval. Saving writes it into the plugin's config store with everything else, so it survives plugin upgrades and you never have to hand-edit config.json.
It only writes a title it took over itself: the plugin touches document.title only while that title is its own. If it never took one over — or already handed it back — it does not touch it at all, including the session title the host writes (<session> — DeepSeek Harness) and titles written by other plugins. Turning the switch off hands back the last host-written title and stops touching the title for good.
Fixed in v0.27.0. The old rule was "if the current title differs from the value cached at start-up, write it back", which overwrote any other writer — classically oh-my-dsh's brand rename (it rewrites a trailing DeepSeek Harness to Oh My DSH). The value read back could never equal the value written, so the title was rewritten every tick (scripts/title-coexistence-test.html measures 10 rewrites in 2.6 s with the session title wiped off the tab; 1 write after the fix).
Presets & Scheduling
Preset JSON and schedule rules
A preset is a named bank snapshot (optionally with its own config), switched from the settings page or automatically by schedule rules:
"presets": [{ "id": "night", "name": "Night", "phrases": { "zh": { "thinking": ["夜深了…"] } } }],
"activePreset": null,
"schedule": [{ "preset": "night", "days": [1,2,3,4,5], "from": "22:00", "to": "06:00" }]
days runs 0 (Sunday) to 6 (Saturday); from / to may cross midnight (22:00 → 06:00);
- While a window matches, that preset is active; outside it the plugin returns to
activePreset. The Automation tab has a visual editor and shows the effective preset live;
- Keys a preset leaves out fall back to the global config.
Extending it from another plugin
Registration API and lifecycle
Since v0.31.0 the browser half publishes a registration API on the host context, so another plugin can contribute content without touching this plugin's source:
// in the other plugin's client half
export const inject = ["statusRotator"]; // or ctx.get("statusRotator") when it is optional
export function apply(ctx) {
const api = ctx.statusRotator;
api.registerPack({ id: "my-pack", label: { zh: "我的词库", en: "My pack" },
phrases: { zh: { running: ["正在替我干活…"] } } });
api.registerPlaceholder("myState", () => "3 项");
api.registerPhraseProvider({ id: "my-provider", provide: (site) => (
site.phase === "long" ? [{ text: "第三方条件句…", when: { phase: "long" } }] : []
) });
}
| Call | Adds |
|---|
registerPlaceholder(name, resolve, { live }?) | a {name} placeholder usable in phrases and title templates |
registerPhraseProvider(fn | { id, provide }) | a dynamic phrase source, re-read on every rotation |
registerPack({ id, label?, phrases }) | a named bank, merged exactly like a document pack |
sendDanmaku(text) | fly one of your own lines across as a scrolling bullet (queued while the host mask pauses the layer) |
- All three return an unregister function — call it when your plugin unloads, and your content goes away with it;
- Registering the same name/id twice, or claiming a built-in placeholder name (
elapsed, pending, phase, …), throws; a provider that throws on its first call, or returns something that is not entries, throws at registration. Failures are explicit — never "silently nothing appears";
- A provider that only breaks later is recorded in
status().failures, warned about once, and skipped for that pick; the status line keeps running;
- Providers receive
{ locale, phase } and return an array of entries (the same shapes as the bank: a plain string or { text, weight, when, rarity }), or a { thinking, running, long } table. An empty array is a valid "nothing right now";
- External content goes through the same pipeline as the shipped bank — pack dedup by text,
when / rarity, the shuffle bag — and : registering writes no config, and a settings save neither persists nor drops it;
Configuration
Use the settings page for everyday edits. Saved settings live at $DSH_HOME/status-rotator/config.json; the external bank is phrases.json in the same directory. Bank updates are fetched every 6 hours by default and can be disabled.
Files, hot reload, bank updates and full parameter reference
Phrases are fully separated from the source code and live in JSON config files. There are two config files at the project root:
config.example.json — the complete template committed to the repo: default config + all phrases (bilingual, split into three phases);
config.json — your local personalized config, initialized by node gen-config.cjs (only created when missing, never overwrites your changes). It's in .gitignore, so edit freely without polluting git.
Auto-loading + push (default): the plugin's node half registers two HTTP routes —
/plugins/dsh-status-rotator/config.json (the document) and /plugins/dsh-status-rotator/events (an SSE channel). The browser opens the SSE channel on start and the channel, not the page, is what triggers a re-read: as soon as anything changes — a settings save, a hand-edited config.json, a bank update — the server pushes a notification and open pages re-read immediately. Separately, the status line's fallback rescan is no longer a resident 2-second poll: it now defaults to 10 seconds (rescanIntervalMs, adjustable, 0 turns it off) and waits for an idle slice before running; new-node adoption, overflow clipping, host overlay changes and late sessions readiness are each handled event-driven (DOM / size observers, targeted attribute observation, bounded retries) — issue #166. While the channel is connected the reloadIntervalMs poll is suspended (near-zero cost for a page left open); if the channel is unavailable or drops, polling comes back automatically, and because the server re-sends the current ETag on every (re)connect, a client that missed changes while disconnected syncs the moment it reconnects. The only restart of dsh web needed is on first install.
Writing the config from outside (ETag / If-Match)
Every GET carries an ETag; send it back as If-Match and a write based on a stale copy is rejected instead of clobbering the other writer:
| Request | Result |
|---|
PUT without If-Match | 200 — an unconditional write. Preconditions are the client's choice in HTTP, so existing scripts keep working; they simply get no concurrency protection |
|
QQ Group Member Phrase Generator
OneBot setup and generator commands
To turn every member of a QQ group into a phrase like 正在路由(群成员)写代码... (meaning "routing (group member) to write code..."), use scripts/fetch-qq-group.cjs to generate a standalone config file in one go — no need to type out the member list by hand.
Prerequisites: the bot is in the target group and you have a OneBot v11 compatible HTTP API (e.g. NapCat / LLOneBot / go-cqhttp / OpenShamrock).
# The default group is a placeholder (0) — always pass your own with -g; generates config.qq0.json otherwise
node scripts/fetch-qq-group.cjs --group 123456789 --url http://localhost:3000 --token your-token
# Directly replace the config.json the plugin actually uses (the old one is backed up as config.backup-<timestamp>.json)
node scripts/fetch-qq-group.cjs --group 123456789 --url http://localhost:3000 --token your-token --activate
# No bot API? Save the member list as members.txt (one nickname per line) and generate from it
node scripts/fetch-qq-group.cjs --input members.txt
| Option | Default | Description |
|---|
-g, --group | 0 (placeholder) | QQ group ID (also reads the QQ_GROUP_ID env var). 0 is meaningless on purpose — always pass a real group id, e.g. --group 123456789 |
-u, --url | http://localhost:3000 | OneBot HTTP URL (also reads ONEBOT_HTTP_URL) |
-t, --token | empty | Access token (also reads ONEBOT_ACCESS_TOKEN) |
-a, --action | get_group_member_list | Action path (also reads ONEBOT_ACTION); frameworks with a prefix use /api/... |
-i, --input | none | Local member list: txt (one per line) / json (array) / csv (first column) |
-o, --output | config.qq0.json | Output file |
--activate | off | Write back to config.json directly and back up the old file |
--dry-run | off | Preview only, writes nothing |
The display name prefers the group card name, falling back to the nickname. The generated file contains only the group: per this plugin's fallback rules, the thinking phase uses it directly and the other phases fall back to the same group. The generated is gitignored.
Project Structure
Repository tree and development files
dsh-status-rotator/
├── .github/
│ ├── workflows/
│ │ ├── phrase-submit.yml # phrase-submission bot (issue opened → validate → auto-PR)
│ │ ├── release.yml # GitHub Release on tag push
│ │ ├── star-pack.yml # refreshes star-ask / star-route with the repo's GITHUB_TOKEN
│ │ └── test.yml # npm test on every push / PR
│ └── ISSUE_TEMPLATE/
│ └── phrase-submit.yml # "Phrase Submission" form (auto-applies the 词库投稿 label)
├── lib/
│ ├── index.js # node half: registers the HTTP route for config.json (GET/PUT, validated)
│ └── client.js # client half: status text replacement / placeholders / gradient / title / danmaku / presets
├── config.example.json # complete template (default config + bilingual theme packs, committed)
├── config.json # local personalized config (gitignored)
├── gen-config.cjs # script that initializes config.json
├── cordis.patch.yml # dsh bundle patch manifest (referenced by package.json dsh.bundle.patch)
├── scripts/
│ ├── fetch-qq-group.cjs # fetches QQ group members and generates the phrase config
│ ├── check-bank-memes.mjs # dev-only bank audit (dups / length / ellipsis / series share)
│ ├── danmaku-mount-test.html # dev-only browser regression page for the danmaku mount point
│ ├── label-layout-test.html # dev-only: status-line layout (width lock / clipping / color fallback / settings render)
│ ├── live-pending-test.html # dev-only: {pending} live refresh (pending-interaction events → label)
│ ├── title-coexistence-test.html # dev-only: tab-title ownership / coexisting with oh-my-dsh's brand rename (v0.27.0)
│ ├── issue-141-coexist-test.html # dev-only: status line coexisting with dsh-claude-style's wait-line CSS (#141)
│ ├── run-issue-141-coexist-test.cjs # dev-only (also runs in CI): drives the #141 coexistence page
│ ├── run-danmaku-mount-test.cjs # dev-only: drives any regression page (--page=danmaku|label|pending|title)
│ ├── turn-process-017-test.html # dev-only: 0.1.7+ status line (header takeover / simple turn / host-text fallback / 0.1.6 look)
│ ├── run-turn-process-test.cjs # dev-only: drives the 0.1.7+ status-line page
│ ├── probe-danmaku-live.cjs # dev-only: inspects the live dsh web page (mount point / paint order)
│ ├── package-release.cjs # packages release files
│ ├── phrase-bot.cjs # phrase-submission bot (parse form / validate / apply / open PR)
│ ├── smoke-test.cjs # pure-function smoke tests (npm test)
│ ├── sync-bank-counts.cjs # keeps README/package.json counts in sync with the bank (bot + star-pack call it)
│ ├── update-star-pack.cjs # rebuilds star-ask / star-route from the stargazer list
│ ├── verify-phrase-hot-reload.cjs # dev-only: proves the external bank hot-reloads in one process
│ ├── verify-bank-auto-update.cjs # dev-only: proves the bank auto-updates from a local upstream in one process
│ ├── verify-settings-survive-upgrade.cjs # dev-only (also runs in CI): settings survive an upgrade for hand-edited config.json and settings-page saves (#51)
│ └── unify-ellipsis.cjs # default-bank ellipsis normalization / integrity check
├── package.json
├── README.md # English docs
├── README_ZH.md # Chinese docs
├── CHANGELOG.md # changelog
├── CONTRIBUTORS.md # English contributors
├── CONTRIBUTORS_ZH.md # Chinese contributors
└── LICENSE
Contributing Phrases via GitHub Issues
Since 0.37.0 you can stay inside the settings page: DSH Settings → Status Texts → Submit — pick language / phase / target pack, write the phrases, get the bot's own rules validated locally (semicolons, links, HTML, length, counts, duplicates are reported with reasons), then hit "Open submission page" to land on a GitHub issue with the body already filled in; the bot takes over from there. The GitHub form below still works when you prefer it.
Ads are welcome too: pick the ads target pack in the submission form — one line per ad, written as a progressive phrase plus its source, e.g. 正在优化提示词 github.com/WestFox-AwA/dsh-prompt-optimizer…; the bot validates and opens the PR as usual. The ads pack is off by default; users can enable it in the settings page.
Pick the 「词库投稿」 form on the Issues page: language (zh / en / both), group (thinking / running / long), target pack (community by default), phrases (one per line, up to 60, ≤200 chars each) and an optional signature.
What gets rejected: an in-line semicolon — ; ; ﹔ ; (a semicolon-joined line only renders as one unreadable run-on; Chinese colon :, comma , and enumeration comma 、 are fine) — plus HTML / links / control characters, duplicates of the existing bank, and an unticked submission checklist. A rejection comes with a ❌ comment listing the reasons; fix and resubmit.
The bot then validates and normalizes (... → …, trailing … appended), replies on the issue with a preview table and a "try it now" JSON (paste it into the settings page to see it immediately), and opens a PR editing config.example.json (tagged 词库投稿) — the maintainer clicks Merge and it ships with the next npm release.
- Branches follow
main: whenever main moves (and every 6 hours, or on demand) the bot rebuilds every open submission branch on the current main, so PRs stay mergeable — nobody has to hand-resolve a config.example.json conflict (which easily produces duplicate keys that JSON.parse silently collapses, losing entries);
- Rule changes are re-checked: a submission that no longer complies gets the reasons on the PR and the issue and its PR is closed automatically; the bot also checks for duplicate keys on every bank read and after every write, and
npm test asserts config.example.json has none;
- Submissions only append to the target pack — no code changes, no touching the core bank — and merged ones are credited in CONTRIBUTORS.md. Implementation: .github/workflows/phrase-submit.yml and
scripts/phrase-bot.cjs.
Testing
Logic checks, HTTP verification and browser cases
npm test (node scripts/smoke-test.cjs) loads lib/client.js in a Node sandbox and asserts the pure logic: placeholder interpolation, duration formatting, clock parsing, config / preset / schedule normalization, schedule matching, the config validation of the node half, and that the documented counts match the bank; CI runs it on every push / PR (.github/workflows/test.yml).
node scripts/verify-settings-page.cjs uses the same sandbox to check the settings page as a standalone unit: it is instantiated from the module-level createSettingsPage(deps) factory with eight explicit dependencies, renders its four tabs, registers its dictionaries through the injected locale / effect, and releases its own stylesheet on dispose(). It fails if the factory ever reaches back into apply's scope, so the extraction cannot silently regress into a closure capture.
node scripts/verify-phrase-selection.cjs pins the phrase-selection engine: the shuffle bag (no repeat within a cycle, none across the refill boundary, memory keyed by language + phase, snapshot round trip), the when conditions and rarity rolls, the exact round trip of the settings editor's line syntax, and the node half's validation / comparison key. These are the failures you cannot spot by eye: a phrase silently skipped, a conditional line appearing at the wrong moment, or one settings save dropping a condition.
Everything that needs a live DOM (danmaku mounting, status-line width lock / clipping / color fallback, the live {pending} refresh, tab-title ownership) has dedicated real-browser regression pages, driven headlessly through CDP by npm run test:browser (needs a local Edge/Chrome):
| Page | Covers |
|---|
danmaku-mount-test.html | mount timing, top/bottom bullets, pause and recovery behind a full-screen host blur mask |
|
Uninstall
Remove the status-rotator line from cordis.patch.yml and restart dsh web.
Contributing
Issues and pull requests are welcome — read the contributing guide first; participation means agreeing to the Code of Conduct, and security problems go through the private channel in SECURITY.md rather than a public issue. The easiest way to add phrases: edit the phrases field in config.json or config.example.json directly — no code changes needed. Or use the phrase-submission form and let the bot validate and open the PR for you.
Credits
This project wouldn't exist without the help of its contributors — see CONTRIBUTORS.md.
License
MIT
Star history
View star history
Star History Chart
Chart generated by star-history.com; it updates as stars come in.